Appearance
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
bash
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 JSONSource 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 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)
jsonc
{
"schema": "bob-launchpad-links/1",
"projects": [
{
"name": "tct", // ledger key (provisions/ports.json)
"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)
"project": "https://github.com/users/paulirv/projects/7" // only with a cdproj gh_project linkage (#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
}
]
}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
Every value comes from a declared source; there are no hand-maintained entries anywhere in the pipeline.
| Field | Declared source | Rule |
|---|---|---|
| coverage | BOB_SOURCE/provisions/ports.json | one entry per ledger project, sorted by name |
ports | ledger band | base + sub-offsets per the port allocation convention |
path | ledger entry path, else ~/.claude/projects.json | the ledger entry's own declared path wins (#1406 — recorded $HOME-contracted at band allocation by port-project-sync, so every cdi 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 |
github.project | provisions/<name>.json gh_project block | the Projects v2 board URL (https://github.com/users/<owner>/projects/<number>), only when the cdproj linkage is enabled and initialised (number written back by cdproj init); owner from gh_project.owner, else the repo owner; anything less → omitted (#1392) |
stack, deploy_path | project files | shared fleet readiness detection (scripts/fleet/readiness.js, #446) |
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)
The port ledger owns name→path. port-project-sync records each project's checkout path onto its ledger entry at band-allocation time (every cdi 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
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)
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 — thedev-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.
jsonc
// 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)
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"}. 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 samedev-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 seconddev-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.
Consumers
The index feeds the Launchpad page (#1244) and its liveness badges (#1246) — 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.
Tests
make test-launchpad-links — dependency-free unit suite (tests/test-launchpad-links.js) covering URL-derivation precedence, route/remote parsing, degradation, and byte-identical re-runs. 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 and the startable-only affordance; 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).