Launch and manage the crux cockpit — the read-only browser GUI over a crux research vault (`crux serve`). This skill runs it beginning to finish: locate the vault, start the server fresh on localhost, verify it actually serves, and hand the user one clickable URL — plus status / stop / restart, and a setup-or-demo path when no vault exists yet. Works wherever the agent runs: local machine, VS Code Remote-SSH (drives the port forward itself via `code --openExternal`), or a plain SSH terminal (...
Scanned 9/4/2026
Install to Claude Code
npx -y skills add mehdiforoozandeh/crux --skill crux-cockpit --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Crux Cockpit?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/mehdiforoozandeh-crux-cockpit)More formats (shields.io, HTML) on the badges page.
---
name: crux-cockpit
description: >-
Launch and manage the crux cockpit — the read-only browser GUI over a crux research
vault (`crux serve`). This skill runs it beginning to finish: locate the vault, start
the server fresh on localhost, verify it actually serves, and hand the user one
clickable URL — plus status / stop / restart, and a setup-or-demo path when no vault
exists yet. Works wherever the agent runs: local machine, VS Code Remote-SSH (drives
the port forward itself via `code --openExternal`), or a plain SSH terminal (hands the
user the exact `ssh -L` tunnel command). Use when a crux user wants to see their vault in a browser.
Triggers: "open the cockpit", "launch the crux GUI", "show me the tree in a browser",
"serve the vault", "is the cockpit running", "stop the cockpit", "restart the
cockpit", crux gui, crux cockpit.
license: MIT
metadata:
author: Mehdi Foroozandeh
version: "1.0"
requires: "the crux skill, installed as a sibling of this one (drives its engine at <crux skill>/scaffold/)"
notice: "Playbook only — operates the bundled crux engine's `serve` verb; no engine changes, no third-party code."
---
# crux-cockpit — launch the crux GUI, beginning to finish
The **cockpit** is crux's read-only browser GUI: `crux serve` boots a stdlib HTTP server
on `127.0.0.1`, serves the `webui/` frontend, and exposes the vault as `/snapshot.json`
(status-colored tree — pan / zoom / collapse / search / re-orient / focus one question —
plus the review queue, node detail with rendered markdown, and a hypothesis's linked report
and figures; live-refreshing, zero writes). Either panel can go full-screen (`[` / `]`,
`Esc` restores). The engine does the serving; **you (the
agent) do the operating**: find the vault, launch clean, verify, hand over one URL, and
manage the server's lifecycle. The user should never have to touch a terminal **on the
machine you run on** — the one thing you can't do for them is start a local `ssh -L`
tunnel (plain-SSH contexts only; see **Local vs remote**).
The engine rides in the **crux** skill: `ENGINE = <crux skill>/scaffold/crux.py` — the
crux skill is this skill's sibling directory in your skills dir (in a repo clone,
`skills/crux`). If that engine file is missing, the crux skill isn't installed: install
it first (`npx skills add mehdiforoozandeh/crux --all`, or the repo's `./install.sh`)
before continuing.
## Ground rules (load-bearing)
- **Never report an unverified URL.** Before telling the user the cockpit is up, both `/`
and `/snapshot.json` must have answered 200 from *your* curl. A URL you haven't verified
does not leave your mouth.
- **URL only — never pop a browser.** Always launch with `--no-open` and deliver the URL
as a clickable link. The user opens it when ready. **One carve-out:** in VS Code
Remote-SSH the sanctioned way to create the port forward *is* an open call
(`code --openExternal` — see the ladder below); there, opening the user's browser is
the mechanism, not a discourtesy.
- **Always fresh, per vault.** A launch first kills any cockpit already serving *that
vault* — and never touches cockpits serving *other* vaults (several can coexist on
different ports).
- **The cockpit is read-only.** No route writes; every mutation stays in the agent/CLI.
Never present it as an editor.
- **Surface engine-drift warnings.** If the launch log warns that the vault was stamped by
a different engine version, relay that warning to the user verbatim.
## Launch protocol
**1 — Locate the vault.** The nearest directory at-or-above cwd containing `.crux.yaml`.
If the user named a vault, use that. If more than one vault is plausible in the project,
ask which — don't guess.
**2 — No vault anywhere?** Offer two paths and let the user pick:
- **Set up a real vault** — hand off to the **crux** skill's setup interview (seed outline
→ approve → `init --from`), then come back here.
- **A disposable demo vault** — so they can explore the GUI in seconds:
`python3 <crux skill>/scaffold/selftest.py --keep <tmpdir>/crux-demo-vault`
(build it *outside* their repo, e.g. under `$TMPDIR`, and say clearly that it's
throwaway sample data).
**3 — Kill stale servers for this vault** (fresh-start rule, scoped by process cwd):
```bash
VAULT="$(cd <vault> && pwd -P)" # -P: physical path — a symlinked route must hash/match identically
VID="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(sys.argv[1].encode()).hexdigest()[:12])' "$VAULT")"
for pid in $(pgrep -f 'crux\.py (serve|gui|ui|cockpit)'); do
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p')"
case "$cwd" in "$VAULT"|"$VAULT"/*) kill "$pid" ;; esac
done
```
(The pgrep pattern can also match a wrapper shell — e.g. a `zsh -c … crux.py serve …`
parent. Its cwd is the same vault, so the loop kills it too; that's correct, it dies
with its child. `kill` is asynchronous: if you're about to relaunch on a **pinned** port,
first wait until `lsof -nP -iTCP:<port> -sTCP:LISTEN` returns nothing — a still-dying
listener makes the pinned relaunch fail with "cannot bind".)
(**No `lsof` on the box?** Minimal cluster images sometimes lack it — and `lsof: command
not found` exits non-zero, which reads exactly like "no match / port free". Never let a
missing tool pass a check. Stdlib substitutes: a port is free iff
`python3 -c 'import socket;socket.socket().bind(("127.0.0.1",<port>))'` succeeds, and on
Linux a PID's cwd is `readlink /proc/<pid>/cwd`.)
**4 — Launch, backgrounded, log captured.** Run from inside the vault so the engine
resolves it; never block your shell on the server. **Remote? Pin first** — if the session
classifies as *any* remote per **Local vs remote** (SSH markers, **or** the tunnel-remote
probes: `$CODESPACES` / `$REMOTE_CONTAINERS` / `/.dockerenv`), derive `$PORT` from the
pin block *below* before launching, and append `--port "$PORT"` to this line:
```bash
LOG="${TMPDIR:-/tmp}/crux-cockpit-$VID.log"
cd "$VAULT" && nohup python3 "$ENGINE" serve --no-open >"$LOG" 2>&1 & # remote: + --port "$PORT"
```
(If your harness has a native run-in-background facility, prefer it — same command, same
captured log. And if shell state doesn't persist between your commands, re-derive
`$VAULT`/`$LOG`/`$URL` in each call rather than assuming the variables survive.)
**Running remotely?** (any remote classification — SSH-marked *or* tunnel-based; see
**Local vs remote** below.) Pin the port with `--port`, and make the pin outlive your session — an auto-picked port
drifts on an always-fresh relaunch (8787 → 8789), silently breaking a tunnel or forward
the user already has open, and "remember it in conversation" dies with the session:
```bash
PIN="$HOME/.cache/crux-cockpit/$VID.port" # $VID from step 3
mkdir -p "${PIN%/*}"
PORT="$(cat "$PIN" 2>/dev/null)"
if [ -z "$PORT" ]; then
PORT=8787
while lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; do PORT=$((PORT+1)); done
echo "$PORT" >"$PIN"
fi
```
Launch with `--port "$PORT"`. If a *recorded* pin is busy at launch even after the step-3
kill loop, a foreign process took it: re-pick, update the pin file, and tell the user
their tunnel/forward target changed. Truly local launches keep auto-pick — no pin file.
**5 — Parse the URL from the banner.** The server prints exactly one line carrying it
(`crux cockpit (read-only) → http://localhost:<port>`; port auto-picked from 8787, or pin
one with `--port`). Poll the log rather than racing it:
```bash
for i in 1 2 3 4 5 6 7 8 9 10; do
URL="$(grep -o 'http://localhost:[0-9]*' "$LOG" | head -1)"
[ -n "$URL" ] && break; sleep 0.5
done
```
(No URL after ~5s → the server died on startup; see **Failure modes**.)
**6 — Verify before reporting.** Both routes, from your own shell:
```bash
curl -fsS "$URL/" >/dev/null
curl -fsS "$URL/snapshot.json" | python3 -c \
'import json,sys; d=json.load(sys.stdin); print(d["project"]["title"], "·", len(d["nodes"]), "nodes")'
```
If either fails: read `$LOG`, diagnose, fix, re-verify. Do **not** hand over the URL on a
failed check.
**7 — Report.** One message: the clickable URL, which vault it serves (project title +
node count from step 6), that it's read-only, and how to end it ("say *stop the cockpit*
when you're done"). **First classify the context** (next section): on a local machine the
URL is the whole story; in any remote context your curl proved the server is up *on the
remote* — the report must also carry whatever gets the user's **local** browser to it.
## Local vs remote — where the browser lives
The server always binds `127.0.0.1` **on the machine you're running on**. Step 6 proves
it's serving *there*; whether the **user's browser** can reach it is a separate question.
Classify from your environment before reporting:
SSH markers = any of `$SSH_CONNECTION`, `$SSH_CLIENT`, `$SSH_TTY`. VS Code markers =
`$TERM_PROGRAM` = `vscode` or `$VSCODE_IPC_HOOK_CLI` set.
| your env | context | what reaches the user |
|---|---|---|
| **none** of the SSH markers set | **local** — your machine is their machine *(after ruling out the tunnel-based remotes below)* | the URL, as-is |
| SSH markers **plus** VS Code markers | **VS Code Remote-SSH** | same URL, once the port is forwarded — you drive the forward (below) |
| SSH markers, **no** VS Code markers | **plain SSH terminal** | nothing yet — they must open a tunnel; you hand them the command |
- **Local** — report the URL; done.
- **VS Code Remote-SSH** — **create the forward yourself; never rely on auto-forwarding.**
It degrades silently, especially on shared hosts (HPC login nodes): once >20 ports have
ever auto-forwarded, VS Code flips `remote.autoForwardPortsSource` to `hybrid` and stops
detecting; ports already listening when VS Code connected are never auto-forwarded; and
your launch writes the banner to a log file, so the output-watcher never sees the URL.
The ladder, in order:
1. **Drive it from the remote CLI** (zero user action):
```bash
[ -n "$VSCODE_IPC_HOOK_CLI" ] && command -v code >/dev/null && code --openExternal "$URL"
```
This asks the VS Code **client** to open the URL; the client creates the port
forward itself and opens the user's local default browser — it works even with
auto-forwarding disabled. (`"$BROWSER" "$URL"` is the same mechanism — VS Code
points `$BROWSER` at a helper that calls `--openExternal`.) Tell the user what
happened: "your browser just opened the cockpit; the port is forwarded." You cannot
confirm the forward from the remote side, so ask once — "did it open?". A failed
guard counts the same as a "no". VS Code *forks* (Cursor, Windsurf, …) set the same
env markers and ship the same remote CLI under their own binary name — if `code`
isn't on PATH, try the fork's name (`cursor`, `windsurf`) with the same
`--openExternal` flag before leaving this rung.
2. **Cmd/Ctrl-click in the integrated terminal.** First `echo "$URL"` in the terminal
so a *terminal-rendered* link exists — a localhost URL clicked in the terminal
forwards on the fly (`remote.forwardOnOpen`, default on). **A URL clicked in a
chat/webview panel does NOT forward** — it opens the local browser on an unforwarded
port and fails. Say this explicitly; it is the classic trap.
3. **Manual forward**: **Ports** panel (the tab next to TERMINAL — or Cmd/Ctrl+Shift+P →
*Ports: Focus on Ports View*) → **Forward a Port** → `<port>`.
4. The filled-in `ssh -L` one-liner (SSH remotes only — skip this rung in tunnel-based
remotes, which have no SSH endpoint; there, rung 3 is the last resort).
Move down a rung whenever the current rung's guard fails or the user says it didn't
work — and deliver rungs 2–4 together in one message, not one at a time.
- **Plain SSH** — the tunnel must be started **from their local machine**; you cannot
create it from the remote side. Give them the exact command, filled in:
```bash
ssh -L <port>:localhost:<port> <user>@<host> # run on your LOCAL machine; keep it open
```
then the same `http://localhost:<port>` works in their local browser. Fill `<user>` from
`whoami` and `<host>` from `hostname -f`; if that's not the name they actually SSH to
(common on clusters), field 3 of `$SSH_CONNECTION` is the server IP — or simply ask
"what do you type to connect here?". **But when the typed name ≠ this machine, don't
put it after `-L`.** Two cluster cases: you're on a compute node behind a login node,
or the typed name is a round-robin alias (`cedar.computecanada.ca`-style) that may land
the tunnel on a *different* login node than the one running the server. Either way,
jump through the typed name and pin the far end to *this* host:
`ssh -J <typed-name> -L <port>:localhost:<port> $(hostname -f)`. Two things to say out
loud: run it **locally**, and keep that terminal open while browsing.
(serve's own banner prints a VS Code-flavoured forwarding hint even here — your tunnel
instructions supersede it; don't relay it.)
In any remote context, word the report so *verified* and *reachable* aren't conflated:
"serving on the remote and verified there; it opens in your browser once the
forward/tunnel is up."
**Tunnel-based remotes: VS Code markers, no SSH markers.** Not every remote arrives over
SSH — GitHub Codespaces, VS Code Tunnels, and Dev Containers set the VS Code markers but
no `SSH_*` at all, and localhost-as-is does **not** reach the user there. Rule them out
before calling a session local: `$CODESPACES` = `true` (Codespaces), `$REMOTE_CONTAINERS`
= `true` or `/.dockerenv` exists (a container; podman writes `/run/.containerenv`
instead). Any of those → use the **VS Code ladder**:
rung 1 (`code --openExternal`) works over tunnels exactly as over SSH; there is no
`ssh -L` rung, so the Ports panel is the true last resort. WSL (`$WSL_DISTRO_NAME` set)
is the benign case — WSL2 forwards localhost to Windows by default, so the URL usually
works as-is; if it doesn't, same ladder.
**When the markers lie.** tmux/screen can strip or *preserve stale* env (`$SSH_*` gone, or
a `$VSCODE_IPC_HOOK_CLI` left over from an earlier VS Code session); mosh sets no SSH vars
at all. Concrete probes when the table's answer smells wrong: `$SSH_CLIENT` often survives
where `$SSH_CONNECTION` didn't; `$TMUX` set means the env may predate the current
connection; `who am i` shows the connecting host for SSH logins. When still unsure, ask
one question — "are you SSH'd into this machine?" — and classify from the answer. And
because a stale VS Code marker misroutes a plain-SSH user, **every SSH-remote report
includes the filled-in `ssh -L` one-liner as the last-resort fallback**, even in VS Code
mode. (Tunnel-based remotes are the exception — no SSH endpoint exists, so omit it and
make the Ports panel the last resort there.)
**Other editors and agents — nothing above may leak into the local flow.** Everything
remote-specific (pin files, the forwarding ladder, tunnel commands, opening a browser) is
gated on the env markers. In their absence — Claude Code desktop, Codex, Cursor or Copilot
on the user's own machine, any plain local terminal — the flow is exactly the local one:
auto-picked port, no pin file, report the verified URL, never open a browser. VS Code
forks over SSH (Cursor, Windsurf) set the same markers and carry the same remote-CLI
mechanism under their own binary name; each ladder rung degrades to the next if a fork
lacks a piece.
## Status · stop · restart
- **Status** ("is the cockpit running?") — enumerate `pgrep -f 'crux\.py (serve|gui|ui|cockpit)'`;
for each PID report its vault (cwd via `lsof -a -p <pid> -d cwd -Fn`) and port
(`lsof -a -p <pid> -iTCP -sTCP:LISTEN -P`), and confirm the URL still answers before
calling it alive. Count only PIDs that actually hold a LISTEN port — a wrapper shell can
match the pattern while listening on nothing. No servers → say so plainly.
- **Stop** ("stop/close the cockpit") — kill that vault's PID(s) (step-3 loop), then confirm:
process gone (`kill -0 <pid>` fails) and port freed
(`lsof -nP -iTCP:<port> -sTCP:LISTEN` returns nothing). Report it stopped. If servers for
several vaults are running and the user didn't say which, ask.
- **Restart** — just run the launch protocol; the fresh-start rule *is* the restart.
## Failure modes worth knowing
- **Banner never appears / process exits immediately** → read `$LOG`: a `crux: error:` line
means the vault didn't resolve (wrong dir) or a pinned `--port` was busy. Fix and relaunch.
- **URL answers but `/snapshot.json` 500s** → the vault is structurally broken; run
`python3 "$ENGINE" validate` in the vault and surface the findings.
- **Zombie from a crashed session** holding a port → the step-3 kill loop clears it; on
local (auto-pick) launches a survivor you *shouldn't* kill (another vault's server) never
blocks a new launch. On **pinned remote** launches it does block — that's the step-4
re-pick rule: new port, update the pin file, tell the user the forward/tunnel target
changed.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!