Skip to content

Launchpad Links Index

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.

← docs home

Terminal window
node ~/.claude/scripts/launchpad/links-index.js # pretty JSON to stdout
node ~/.claude/scripts/launchpad/links-index.js --out FILE # write to FILE instead
node ~/.claude/scripts/launchpad/links-index.js --compact # single-line JSON
node ~/.claude/scripts/launchpad/links-index.js --ledger # every ledger project, ignoring the fleet feed

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

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

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)

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.

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 — the bob dev health convention, 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.json fetch fails (static file, older server) keeps its hollow badges. Nothing is ever guessed.
  • Local only. Only localhost / 127.0.0.1 URLs from the index are probed — the dev, docs, and dashboard URLs. 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.json per 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": {}
}
}

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/start with {"project": "<name>", "surface": "dev"}.
  • Same origin only (#3642). Every action route (/action/start, /action/layout) answers 403, starting and writing nothing, unless the request carries Content-Type: application/json, a Host that names the launchpad (loopback, an IP, a single-label MagicDNS name or a *.ts.net name) and, when the browser sends one, an Origin whose host is that Host. A form post from another web page cannot set the JSON type, and a cross-origin fetch that 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 gets 405 + Allow: POST, so no link, prefetch, or crawler can ever trigger a side effect. The server binds 127.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. a dev.json exists at its registered path. The page shows no start affordance otherwise, and the endpoint refuses (422) with a concrete reason (unknown project, unregistered path, no dev.json, undeclared surface). The start always runs through the same bob dev up entrypoint 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 second bob 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_out when that happens.
  • Results. 200 on success; 502 on 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 in scripts/lib/derived-paths.json under machine_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 by make check-schemas (the committed sample validates, the rejected fixture — an unknown view, a span of 4 — is refused). additionalProperties is refused at both levels; span is an integer enum 1 | 2 | 3 (the in-repo validator implements minimum but not maximum, and an enum is honoured by every validator alike).
  • Routes. GET /layout.json answers the file, or the empty default when it is absent, unreadable, unparsable or invalid — 200 in every case, never an error, so deleting the file restores the default on the next load. POST /action/layout takes the whole document as its body: it is schema-validated before any write and an invalid body is refused with 422 {ok:false, reason, errors} and no file change; a non-JSON body is 400; a valid one is 200 {ok:true, layout}. Like every /action/* route it is POST-only — a GET is 405 + Allow: POST.
  • Atomic write. A valid layout lands in a temp file in the same directory and is rename()d over layout.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() in scripts/launchpad/layout.js, whose source the page embeds verbatim so the browser and the server cannot disagree:
    • a project named in tiles sorts first, ascending by order (ties keep index order) and carries its span;
    • 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.
  • Page. render-page.js never reads the file — its output is byte-identical whether the file is absent, present, or different. The inline script fetches /layout.json once, after the filter and liveness bootstrap, and applies it client-side (rows are moved, stamped data-span and data-arranged, and body gets data-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 in localStorage (bob-launchpad-layout), every storage access guarded, and any failure at all leaves the default in place — the page never errors. window.bobLaunchpad exposes apply / load / save (save POSTs when served, writes localStorage alone 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 its order (0-based, contiguous) and its span, an out-of-range span as 1. moveBefore and cycleSpan are the other two gesture rules. All three live in layout.js beside orderNames() 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.

Terminal window
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.json
node ~/.claude/scripts/launchpad/layout.js validate FILE # exit 1 with the violations when invalid

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.