Launchpad Links Index
Launchpad Links Index (#1243)
Section titled “Launchpad Links Index (#1243)”The machine-readable per-project link map behind the Launchpad. Schema of the generated index, the derivation rules (declared sources only — nothing guessed), and how to run the generator.
Generator
Section titled “Generator”node ~/.claude/scripts/launchpad/links-index.js # pretty JSON to stdoutnode ~/.claude/scripts/launchpad/links-index.js --out FILE # write to FILE insteadnode ~/.claude/scripts/launchpad/links-index.js --compact # single-line JSONnode ~/.claude/scripts/launchpad/links-index.js --ledger # every ledger project, ignoring the fleet feedThe project list is not the generator’s own scan (#2081): it is this
machine’s live row of the fleet feed, built with the loader
the one dashboard server (scripts/dashboard/server.js) uses for
/fleet.json. The Launchpad server reuses a built feed for
LAUNCHPAD_FEED_REFRESH_S seconds (default 10, the dashboard’s own refresh
default), so a page load and its /liveness.json follow-up share one build.
Source of truth: BOB_SOURCE/scripts/launchpad/links-index.js (deployed to
~/.claude/scripts/ by deploy.sh).
Like the fleet readiness probe (scripts/fleet/readiness.js, #446), the
generator is static and side-effect-free — it reads declared files only
and never executes bob dev up, a server, or a deploy. It is also
deterministic: no timestamps, projects sorted by name — re-running with
unchanged inputs produces byte-identical output, so the emitted index can be
diffed and cached safely.
Index schema (bob-launchpad-links/1)
Section titled “Index schema (bob-launchpad-links/1)”{ "schema": "bob-launchpad-links/1", "source": "fleet-feed", // fleet-feed | ledger — where the project list came from (#2081) "band_width": 10, // ports per band — the ledger's _meta.band_width, else 10; the port map draws band ends from it (#2143) "projects": [ { "name": "tct", // project name (fleet feed row / ledger key) "slot": 76, // ledger slot (absent for pinned entries) "base": 5960, // band base port "pinned": true, // only when the ledger entry is pinned "ports": { // band sub-offsets (port-allocation.md) "app": 5960, // base+0 Vite/app dev "wrangler": 5961, // base+1 Wrangler dev "dashboard": 5962, // base+2 cds dashboard "docs": 5963 // base+3 docs-site dev }, "path": "$HOME/Sites/tct", // declared path (ledger entry, else projects.json), unexpanded "dev_json": "malformed", // only when dev.json exists but fails to parse "stack": ["cloudflare-workers", "node", "vitest", "typescript"], "deploy_path": "cf", // cf | external | none (readiness.js) "github": { "repo": "https://github.com/paulirv/tct", "issues": "https://github.com/paulirv/tct/issues", "actions": "https://github.com/paulirv/tct/actions" // only when CI workflows are declared (#1392) }, "urls": { "dev": "http://localhost:5960", "docs": "http://localhost:5963", // dev.json docs block, else docs-site.json fallback (#1407) "dashboard": "http://localhost:5962", // cds dashboard — ledger band base+2 (#1392) "prod": "https://currentthing.app" // only for cf projects with a declared route }, "startable": ["dev"], // surfaces with a declared start path (#1391): present iff dev.json exists "mirror_host": "farm-01" // machines.json id of the declared git mirror (#2153): absent when not mirrored } ]}Fields that cannot be derived are omitted, never null-filled or guessed. A project with nothing but a ledger entry still appears — with its band and a ledger-derived dev URL.
Derivation rules
Section titled “Derivation rules”Every value comes from a declared source; there are no hand-maintained entries anywhere in the pipeline.
| Field | Declared source | Rule |
|---|---|---|
| coverage | the fleet feed (bob-fleet-feed/1), else the operator repo’s provisions/ports.json (legacy BOB_SOURCE/provisions/ports.json, #2270) |
this machine’s live row of the fleet feed — the document the one dashboard server, scripts/dashboard/server.js, serves at /fleet.json (#2081) — names the projects, sorted by name; source: "fleet-feed". With no feed, or no live row for this machine in it, every ledger project is listed instead and source is "ledger" |
ports |
ledger band | base + sub-offsets per the port allocation convention. The entry is found by name, else by the path it declares — a fleet-feed name from projects.json is not always the ledger key (BoB ↔ .claude), and the mismatch never costs a project its band (#2081) |
path |
ledger entry path, else the operator repo’s projects.json |
the ledger entry’s own declared path wins (#1406 — recorded $HOME-contracted at band allocation by port-project-sync, so every bob init run records or heals it); the legacy registry is the fallback: exact name match, then path-basename match; inactive entries skipped |
urls.dev |
project dev.json |
one resolved port feeds the link, the port badge, and the liveness probe (#1499): server.port, else the ledger app port, is authoritative; access.localhost supplies the URL form (scheme/path) only while its port agrees. A contradicting (stale) access.localhost is overridden and surfaced as access_drift on the entry — never silently trusted. With no port declared anywhere, access.localhost stands alone |
urls.docs |
project dev.json docs block, else docs-site.json |
an explicit dev.json docs block always wins — including enabled: false, which suppresses the pill outright (the opt-out beats any fallback); docs.port → ledger docs port (base+3). With no docs block, a present, non-disabled docs-site.json (the docs-site skill’s declared config, #1407) declares the docs site; it has no port field, so the ledger docs port (base+3) applies — the same band-only derivation as the dashboard pill. Malformed docs-site.json = absent; neither source declared → omitted, never guessed |
urls.dashboard |
ledger band | the cds dashboard sub-offset (base+2, #1392) — present for every banded project (cds auto-assigns that port); omitted when the ledger entry has no base |
urls.prod |
wrangler.toml / .jsonc / .json |
only where deploy path is cf: first declared route/custom-domain host, production env preferred, monorepo apps/<name>/ configs scanned; no declared route → omitted (a workers.dev URL would need the undeclared account subdomain) |
github |
.git/config [remote "origin"] |
parsed statically (no git exec; worktree gitdir: pointers followed); non-GitHub remotes omitted |
github.actions |
.github/workflows/ |
the repo /actions URL, only when at least one .yml/.yaml workflow is declared (#1392) — the same CI detection readiness.js uses |
stack, deploy_path |
project files | shared fleet readiness detection (scripts/fleet/readiness.js, #446) |
mirror_host |
the operator repo’s mirrors.json |
the defaults with the project’s projects[] override merged over them — the same merge scripts/fleet/mirror.js resolve applies, without its fleet lookup, so the value is the declared machine id. Omitted when the project resolves to enabled: false, or when mirrors.json is absent or unusable. The graph view’s mirror edges read it (#2153) |
startable |
project dev.json |
["dev"] when a dev.json exists at the registered path — the declared start path the start action requires; omitted otherwise (no affordance, nothing guessed) |
Path ownership (#1406)
Section titled “Path ownership (#1406)”The port ledger owns name→path. port-project-sync records each
project’s checkout path onto its ledger entry at band-allocation time (every
bob init run), and converges an absent or stale path — but never overwrites a
declared path whose directory still exists, so a sync run from a secondary
checkout (e.g. a cdfork worktree) cannot steal the canonical location. This
closes the drift mode that left 17 ledger projects pathless: the
projects.json registry used to be the only path source and was never
updated for the ~/projects/* generation. The registry remains a read-only
legacy fallback; new drift cannot recur because paths now travel with the
allocation itself.
.claude / BoB aliasing (intended). The ledger’s .claude entry
declares $HOME/.claude — the deployed BOB_HOME runtime dir, whose git
origin is the bigbrain repo. It therefore shows the same repo/issues links
as the bigbrain entry (the source checkout at $HOME/projects/bigbrain).
Both are real, distinct surfaces: one is the deploy target, one the source
of truth.
Graceful degradation
Section titled “Graceful degradation”A missing, partial, or malformed dev.json never drops a project from the
index: the entry keeps whatever is derivable (band ports, ledger-fallback dev
URL, GitHub URLs). A malformed dev.json is additionally flagged with
"dev_json": "malformed" so the Launchpad can surface it.
Liveness probe (bob-launchpad-liveness/1, #1246)
Section titled “Liveness probe (bob-launchpad-liveness/1, #1246)”The one deliberate exception to “static and side-effect-free”: the liveness
probe (scripts/launchpad/liveness.js) answers “is this dev server actually
up right now?” for the page’s badges. Its semantics:
- Verdicts.
up= a TCP connect to the port succeeds (something is listening — thebob dev healthconvention, where any live listener counts);down= the connect is refused or times out. Unknown is the absence of a verdict: a project with no probeable URL gets no key, and a page whose/liveness.jsonfetch fails (static file, older server) keeps its hollow badges. Nothing is ever guessed. - Local only. Only
localhost/127.0.0.1URLs from the index are probed — thedev,docs, anddashboardURLs. Remote hosts (urls.prod, the GitHub links) are never touched: the badge reports local dev liveness, not production health. - Cheap and bounded. All connects run in parallel under a short per-probe
timeout (default 400 ms,
--timeout MS), so a full-fleet sweep completes in roughly one timeout regardless of project count and the page never hangs. - Probe-at-render, never a poller. The module holds no timers, cache, or
state; a probe runs only when asked — one
GET /liveness.jsonper page load/refresh (the serve.js route re-probes per request), or the CLI. The badge is painted by a one-shot post-load fetch, so first paint (the instant-load metric) is never delayed, and a verdict only ever colors the dot — links render identically for up, down, and unknown.
// GET http://localhost:7777/liveness.json · node ~/.claude/scripts/launchpad/liveness.js{ "schema": "bob-launchpad-liveness/1", "timeout_ms": 400, "projects": { "tct": { "dev": "up", "docs": "down" }, // key absent = unknown (nothing declared) "bob-web": {} }}Start action (#1391)
Section titled “Start action (#1391)”Where the liveness probe is the read-only exception, the start action is the Launchpad’s one deliberate write exception: clicking the down dot on a startable dev pill may start that project’s dev server. Everything else the Launchpad does remains a derivation.
- Endpoint.
POST /action/startwith{"project": "<name>", "surface": "dev"}. - Same origin only (#3642). Every action route (
/action/start,/action/layout) answers 403, starting and writing nothing, unless the request carriesContent-Type: application/json, aHostthat names the launchpad (loopback, an IP, a single-label MagicDNS name or a*.ts.netname) and, when the browser sends one, anOriginwhose host is thatHost. A form post from another web page cannot set the JSON type, and a cross-originfetchthat sets it is preflighted, which the server never answers. The launchpad page’s own requests already send all three. Action routes are POST-only — a GET gets405+Allow: POST, so no link, prefetch, or crawler can ever trigger a side effect. The server binds127.0.0.1, so the action is local-only by construction. - Declared start path only. A project is startable iff its index entry
carries
startable: ["dev"]— i.e. adev.jsonexists at its registered path. The page shows no start affordance otherwise, and the endpoint refuses (422) with a concrete reason (unknown project, unregistered path, nodev.json, undeclared surface). The start always runs through the samebob dev upentrypoint the CLI uses — the Launchpad never invents a start sequence. - One in flight per project. A second start request while one runs answers
409(in_flight: true) and never spawns a secondbob dev up; the project becomes startable again when the run settles. - Bounded, non-blocking. The child is spawned asynchronously (the server
keeps answering page/liveness routes) and killed at the timeout (default
180 s); results report
timed_outwhen that happens. - Results.
200on success;502on failure with the short combined output tail (last 2 000 chars) so the page can show why — a failed start is never a silently-red dot. - Page flow. down dot → click-to-start control (
.dot.start) → pulsing in-progress state (.dot.starting) → one liveness re-probe paints the final verdict; failures render an inline message with the output tail.
Layout contract (bob-launchpad-layout/1, #2139)
Section titled “Layout contract (bob-launchpad-layout/1, #2139)”Everything above is a derivation of declared state. The one thing the Launchpad owns that is not derivable is what the user did to it — which view it opens in, and how the projects are arranged. That state has exactly one place: a machine-specific file with a versioned schema, served and written by the launchpad server, and applied by the page after first paint. The rendered page stays a pure function of the links index.
- File.
~/.claude/launchpad/layout.json($BOB_HOME/launchpad/, beside the static page the how-to renders there). It is machine-local derived state:.claude/launchpad/is declared inscripts/lib/derived-paths.jsonundermachine_local, so the managed ignore block and the checkpoint classifier both treat it as BoB’s, never as a repo’s authored work. - Schema.
schemas/launchpad-layout.schema.json, proved both ways bymake check-schemas(the committed sample validates, the rejected fixture — an unknown view, a span of 4 — is refused).additionalPropertiesis refused at both levels;spanis an integer enum1 | 2 | 3(the in-repo validator implementsminimumbut notmaximum, and an enum is honoured by every validator alike). - Routes.
GET /layout.jsonanswers the file, or the empty default when it is absent, unreadable, unparsable or invalid —200in every case, never an error, so deleting the file restores the default on the next load.POST /action/layouttakes the whole document as its body: it is schema-validated before any write and an invalid body is refused with422 {ok:false, reason, errors}and no file change; a non-JSON body is400; a valid one is200 {ok:true, layout}. Like every/action/*route it is POST-only — a GET is405+Allow: POST. - Atomic write. A valid layout lands in a temp file in the same
directory and is
rename()d overlayout.json, so a reader never sees a torn file and a crash mid-write leaves the previous layout intact. A refused write creates nothing — not even the directory. - Merge rules — one function,
orderNames()inscripts/launchpad/layout.js, whose source the page embeds verbatim so the browser and the server cannot disagree:- a project named in
tilessorts first, ascending byorder(ties keep index order) and carries itsspan; - a project in the index but not in the layout appends after every arranged one, in index order, at span 1 — as does one whose entry is malformed;
- a layout entry with no matching project is ignored silently;
- no file, an invalid file, or no layout at all is the default: index
order throughout, view
tiles.
- a project named in
- Page.
render-page.jsnever reads the file — its output is byte-identical whether the file is absent, present, or different. The inline script fetches/layout.jsononce, after the filter and liveness bootstrap, and applies it client-side (rows are moved, stampeddata-spananddata-arranged, andbodygetsdata-view); the filter’s⏎then opens the first visible row in layout order. Opened as a plain file (no server: the fetch rejects) the page falls back to the layout the session last saw inlocalStorage(bob-launchpad-layout), every storage access guarded, and any failure at all leaves the default in place — the page never errors.window.bobLaunchpadexposesapply/load/save(savePOSTs when served, writeslocalStoragealone as a plain file) for the views that build on this contract (#2138). - Writer: the tile gestures (#2140). Dragging a tile,
Alt+←/→and the resize handle each save the whole arrangement:layoutFrom(names, spans, view)writes every project’s position as itsorder(0-based, contiguous) and itsspan, an out-of-range span as 1.moveBeforeandcycleSpanare the other two gesture rules. All three live inlayout.jsbesideorderNames()and are embedded in the page verbatim, so a saved arrangement always reads back in the order it was saved. How to use them: Tiles: reorder and resize.
// GET http://localhost:7777/layout.json · ~/.claude/launchpad/layout.json{ "schema": "bob-launchpad-layout/1", "view": "tiles", // tiles | list | board | portmap | graph "tiles": { "tct": { "order": 0, "span": 2 }, // arranged: first, two columns wide "bob-web": { "order": 1, "span": 1 } // a project not listed here appends after these, in index order, span 1 }, "board": { "groupBy": "stack" }, // optional (#2142): liveness (default) | stack "graph": { "pins": { "tct": { "x": 420, "y": 180 } } } // optional (#2153): dragged graph nodes, canvas units}board was added to /1 additively (#2142): a layout without it is valid and
the board groups by liveness. Every save the page makes — a tile move, a view
switch, a group-key choice — carries the other fields through unchanged, so
choosing a group key never resets the arrangement and moving a tile never
resets the group key. A groupBy other than liveness or stack fails the
schema, and the server refuses it like any invalid layout.
graph was added the same way (#2153), with graph in the view enum. Its
pins map a project’s index name to the point it was dragged to: {x, y}
numbers on the graph’s 800 × 520 canvas (SVG user units), nothing else. A
pinned node never moves. A pin naming no project is ignored. Every other save
carries graph through unchanged, and a pin save carries board and the
tiles through.
node ~/.claude/scripts/launchpad/layout.js show # what this machine reads (file or default, with the reason)node ~/.claude/scripts/launchpad/layout.js path # ~/.claude/launchpad/layout.jsonnode ~/.claude/scripts/launchpad/layout.js validate FILE # exit 1 with the violations when invalidConsumers
Section titled “Consumers”The index feeds the Launchpad page (#1244), its liveness badges (#1246) and
its port map (#2143, from base and band_width) and its graph (#2153, from
stack, mirror_host, base and band_width) —
the page renders links; the generator owns all derivation. Regenerate whenever
declared state changes (a project gains a port band, a dev.json, or a route);
re-running is always safe.
make test-launchpad-links — dependency-free unit suite
(tests/test-launchpad-links.js) covering URL-derivation precedence,
route/remote parsing, degradation, byte-identical re-runs, and the project
list read from the fleet feed’s live row with its ledger fallback (#2081);
make test-launchpad-serve covers the served feed path, the fallback when the
feed loader throws, and the once-per-interval feed cache;
make test-launchpad-layout (tests/test-launchpad-layout.js, #2139) covers
the layout schema on both fixtures, the store (default on absent / corrupt /
invalid, validate-before-write, temp + rename with no residue), both routes
end to end, the merge rules, the byte-identical render across layout-file
states, and the page’s inline script driven under an in-test DOM shim
(served, default, plain file with and without a stored layout, no
localStorage, non-layout response, save as a plain file).
make test-launchpad-liveness covers the probe (local-only guard, up/down
verdicts against real listeners, per-request freshness of /liveness.json);
make test-launchpad-page pins the badge-informs-never-hides page contract,
the startable-only affordance, the tile markup and grid rules, the gesture
functions’ failure modes, and drives the page’s script for drag, Alt+←/→,
resize, the filter and reduced motion (#2140); make test-launchpad-start covers the
start action (start invoked via the declared path, GET rejection, refusal
reasons, in-flight dedupe, failure surfacing, bounded execution).
make test-launchpad-portmap (tests/test-launchpad-portmap.js, #2143)
covers the port map’s model — bands in port order whatever the index order,
no zero-width gap between adjacent bands, exact gap ranges, partial,
same-base and three-way overlaps listed once per pair, a garbage band width
meaning 10 — and the strip’s markup (segments, popover pills, base mark,
warning line, the under-600px list rule) on a page built by links-index.js --ledger → render-page.js --index from a sandboxed fixture ledger.
make test-launchpad-graph (tests/test-launchpad-graph.js, #2153) covers
the graph’s model — no self, duplicate or across-a-gap edges, stack weights,
garbage input, a seed always giving the same layout, 40 nodes settled within
120 ticks and still once settled, pins that hold, no NaN — and its layout
contract (view: graph, graph.pins). make test-launchpad-graph-page
drives the real server in headless Chrome: the 5 key, the legend,
liveness, Tab, hover and focus pills, the dimming filter, drag-to-pin into
layout.json, double-click unpin, reduced motion with no animation frame,
and Enter opening dev; it says SKIP without a headless Chrome.