← Commit history
SKILL.mdadded+74
@@ -0,0 +1,74 @@+---+name: headview+description: "Turn a headless Adom container into a browser-accessible desktop. Runs Xvfb+fluxbox+x11vnc+noVNC inside the container and maps a *.adom.cloud port so any browser (or a Hydrogen webview tab) shows the container's GUI. Trigger words: headless desktop, container desktop, view GUI from container, noVNC, run a GUI app in the container, remote desktop for container, browser desktop, X server in container, see kicad/gui in browser."+---++# headview++`headview` gives a **headless** Adom container a real desktop you can open in a+browser. It starts a virtual X display in the container, scrapes it with VNC,+bridges that to WebSocket with noVNC, and maps a `*.adom.cloud` port so+**Cloudflare serves the page — the container hosts it itself**, no SSH tunnel and+no local VNC viewer. Open the URL anywhere, or drop it into a Hydrogen webview tab.++Why not `ssh -Y`? X11 forwarding does **not** work through the `adom.cloud` SSH+gateway (the gateway relays the connection, so the X11 channel never reaches the+container). `headview` sidesteps that entirely by serving over HTTP/WebSocket,+which the gateway/Cloudflare pass fine.++## Stack++`Xvfb (:99)` → `fluxbox` → `x11vnc (localhost:5900)` → `noVNC/websockify (:6080)` → port-mapping → `https://<prefix>-*.adom.cloud/vnc.html`++Rendering is **software only** (Xvfb has no GPU) — great for 2D GUIs, not for+OpenGL/3D views.++## Commands++### `headview up`+Install deps if missing, start the desktop, map the port, print the URL + password. Idempotent — safe to re-run.++| Flag | Default | Description |+|------|---------|-------------|+| `--display :N` | `:99` | X display number |+| `--geometry WxH` | `1600x1000` | screen size |+| `--vnc-port N` | `5900` | internal VNC port (loopback only) |+| `--web-port N` | `6080` | internal noVNC port that gets mapped |+| `--prefix NAME` | `desktop` | subdomain prefix → `NAME-<rand>.adom.cloud` |+| `--app "CMD"` | — | launch CMD on the desktop after starting |+| `--no-password` | — | passwordless (only behind other access control) |++```bash+headview up                              # bare desktop+headview up --app "kicad"                # desktop with KiCad already open+headview up --geometry 1920x1080+```++### `headview open`+Open the running desktop as a Hydrogen webview tab named "Container Desktop".+Must be run from a **Hydrogen workspace terminal** (needs `VSCODE_PROXY_URI`).+From anywhere else, use `headview url` and open it in a browser.++### `headview status` / `headview url` / `headview password`+Inspect state: what's running, the URL, and the VNC password.++### `headview down`+Stop Xvfb/fluxbox/x11vnc/noVNC and **remove the port mapping**. Add `--keep-mapping` to leave the mapping in place.++### `headview install`+Install/refresh this skill from the wiki into `~/.claude/skills/headview/`.++## Launching apps onto the desktop+Anything you run with `DISPLAY=:99` appears in the browser window:+```bash+DISPLAY=:99 pcbnew &+DISPLAY=:99 xterm &+```++## Notes+- Run `headview` **inside the target container** — it auto-detects the container+  slug via `adom-cli carbon containers current` and maps its own port.+- The VNC server binds `localhost` only; the public face is the noVNC HTTPS URL,+  protected by the generated VNC password (shown by `headview up`/`password`).+- WebSocket idle: Cloudflare can drop very idle WS connections; noVNC reconnects,+  and any desktop activity keeps it warm.
docs/HERO.mdadded+8
@@ -0,0 +1,8 @@+Generate `hero.png` for this folder in Hero Studio. It is the page-header image, reused on the homepage and the screensaver; the page overlays live stats on its lower third, so the image carries the title and artwork only.++New app/skill heroes MUST come from Hero Studio (the server rejects anything else with HERO_STUDIO_REQUIRED; the studio stamps provenance into the PNG and exports at 2000x1250):++1. `adom-wiki pkg install adom/hero-studio`+2. start it (`hero-studio`, port 8846) and open the webview so the human sees it+3. pose the hero (the UI, or POST /state), then Generate+4. copy `generated/latest.png` here as `docs/hero.png`
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-desktop.pngadded
⋯ 1 unchanged line ⋯
headviewadded+306
@@ -0,0 +1,306 @@+#!/usr/bin/env bash+# headview — turn a headless Adom container into a browser-accessible desktop.+#+# Spins up Xvfb + fluxbox + x11vnc + noVNC inside the container, maps a+# *.adom.cloud port so Cloudflare serves it, and prints a URL you can open+# from any browser (or as a Hydrogen webview tab). The container hosts the+# site itself — no SSH tunnel, no local viewer.+#+# Design: AI-oriented output — every line starts with OK: or ERROR:, errors+# carry a Hint:. Colors only on a TTY (stripped when piped/logged).++set -uo pipefail++HEADVIEW_VERSION="0.1.0"++# ---------------------------------------------------------------- output helpers+if [ -t 1 ]; then C_OK=$'\033[32m\033[1m'; C_CYAN=$'\033[36m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'+else C_OK=""; C_CYAN=""; C_YEL=""; C_DIM=""; C_RST=""; fi+if [ -t 2 ]; then E_ERR=$'\033[31m\033[1m'; E_DIM=$'\033[2m'; E_RST=$'\033[0m'; else E_ERR=""; E_DIM=""; E_RST=""; fi++ok()   { printf '%sOK:%s %s\n'   "$C_OK"  "$C_RST" "$*"; }+note() { printf '%s%s%s\n'       "$C_DIM" "$*" "$C_RST"; }+err()  { printf '%sERROR:%s %s\n' "$E_ERR" "$E_RST" "$*" >&2; }+hint() { printf '%sHint:%s %s\n' "$E_DIM" "$E_RST" "$*" >&2; }+die()  { err "$1"; [ -n "${2:-}" ] && hint "$2"; exit 1; }++# ---------------------------------------------------------------- state+STATE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/headview"+STATE_FILE="$STATE_DIR/state.env"+NOVNC_HOME="$HOME/.local/share/novnc"     # git-clone fallback web root+mkdir -p "$STATE_DIR"++save_state() { # key value+  mkdir -p "$STATE_DIR"; touch "$STATE_FILE"+  grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$STATE_FILE.tmp" || true+  echo "$1=$2" >> "$STATE_FILE.tmp"; mv "$STATE_FILE.tmp" "$STATE_FILE"+}+load_state() { [ -f "$STATE_FILE" ] && set -a && . "$STATE_FILE" && set +a || true; }++# ---------------------------------------------------------------- defaults+DISPLAY_NUM=":99"+GEOMETRY="1600x1000"+DEPTH="24"+VNC_PORT="5900"+WEB_PORT="6080"+PREFIX="desktop"+APP=""+USE_PASSWORD="1"++# ---------------------------------------------------------------- deps+have() { command -v "$1" >/dev/null 2>&1; }++novnc_webroot() {+  if [ -f /usr/share/novnc/vnc.html ]; then echo /usr/share/novnc+  elif [ -f "$NOVNC_HOME/vnc.html" ]; then echo "$NOVNC_HOME"+  else echo ""; fi+}++ensure_deps() {+  local need=()+  have Xvfb    || need+=(xvfb)+  have x11vnc  || need+=(x11vnc)+  have fluxbox || need+=(fluxbox)+  have xdpyinfo|| need+=(x11-utils)+  # noVNC + websockify: prefer apt packages+  if [ -z "$(novnc_webroot)" ] || ! have websockify; then need+=(novnc websockify); fi+  if [ ${#need[@]} -gt 0 ]; then+    note "installing: ${need[*]}"+    if have sudo; then sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${need[@]}" >/dev/null 2>&1+    else apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${need[@]}" >/dev/null 2>&1; fi+  fi+  # git-clone fallback if apt didn't provide a web root+  if [ -z "$(novnc_webroot)" ]; then+    note "apt novnc unavailable — cloning noVNC from GitHub"+    have git || die "git not available and noVNC not installed" "sudo apt-get install -y git novnc"+    [ -d "$NOVNC_HOME" ] || git clone --depth 1 -q https://github.com/novnc/noVNC "$NOVNC_HOME" || \+      die "failed to fetch noVNC" "check the container has outbound network to github.com"+    [ -d "$NOVNC_HOME/utils/websockify" ] || git clone --depth 1 -q https://github.com/novnc/websockify "$NOVNC_HOME/utils/websockify" || true+  fi+  for b in Xvfb x11vnc fluxbox; do have "$b" || die "$b still missing after install" "install it manually: sudo apt-get install -y $b"; done+}++# ---------------------------------------------------------------- self slug+container_slug() {+  local j; j="$(adom-cli carbon containers current 2>/dev/null)" || return 1+  printf '%s' "$j" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("slug",""))' 2>/dev/null+}++# find an existing mapped hostname whose internal port == WEB_PORT+existing_hostname_for_port() {+  local port="$1" j+  j="$(adom-cli carbon containers current 2>/dev/null)" || return 1+  printf '%s' "$j" | python3 -c '+import sys,json+p=int(sys.argv[1]); d=json.load(sys.stdin)+for host,port in (d.get("port_mappings") or {}).items():+    if int(port)==p: print(host); break+' "$port" 2>/dev/null+}++start_websockify() {+  local webroot="$1"+  pkill -f "websockify.*:$WEB_PORT" 2>/dev/null; pkill -f "novnc_proxy.*$WEB_PORT" 2>/dev/null; sleep 1+  if have websockify; then+    nohup websockify --web="$webroot" "0.0.0.0:$WEB_PORT" "localhost:$VNC_PORT" >/tmp/headview-web.log 2>&1 &+  else+    nohup "$NOVNC_HOME/utils/novnc_proxy" --vnc "localhost:$VNC_PORT" --listen "$WEB_PORT" >/tmp/headview-web.log 2>&1 &+  fi+}++# ---------------------------------------------------------------- commands+cmd_up() {+  ensure_deps+  local webroot; webroot="$(novnc_webroot)"+  [ -n "$webroot" ] || die "noVNC web root not found" "run: sudo apt-get install -y novnc"++  export DISPLAY="$DISPLAY_NUM"++  # 1) Xvfb+  if ! pgrep -f "Xvfb $DISPLAY_NUM" >/dev/null; then+    nohup Xvfb "$DISPLAY_NUM" -screen 0 "${GEOMETRY}x${DEPTH}" >/tmp/headview-xvfb.log 2>&1 &+    sleep 2+  fi+  pgrep -f "Xvfb $DISPLAY_NUM" >/dev/null || die "Xvfb failed to start" "check /tmp/headview-xvfb.log"++  # 2) window manager+  pgrep -f "fluxbox" >/dev/null || { nohup fluxbox >/tmp/headview-fluxbox.log 2>&1 &  sleep 1; }++  # 3) password+  local pw=""+  if [ "$USE_PASSWORD" = "1" ]; then+    pw="${HEADVIEW_PASSWORD:-}"+    if [ -z "$pw" ]; then pw="hv$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')"; fi+    mkdir -p "$HOME/.vnc"+    x11vnc -storepasswd "$pw" "$HOME/.vnc/passwd" >/dev/null 2>&1+    save_state PASSWORD "$pw"+  else+    save_state PASSWORD ""+  fi++  # 4) x11vnc (loopback only — noVNC is the public face)+  pkill -f "x11vnc.*:$VNC_PORT" 2>/dev/null; pkill -f "x11vnc -display $DISPLAY_NUM" 2>/dev/null; sleep 1+  if [ "$USE_PASSWORD" = "1" ]; then+    nohup x11vnc -display "$DISPLAY_NUM" -localhost -rfbauth "$HOME/.vnc/passwd" -forever -shared -rfbport "$VNC_PORT" -bg >/tmp/headview-x11vnc.log 2>&1+  else+    nohup x11vnc -display "$DISPLAY_NUM" -localhost -nopw -forever -shared -rfbport "$VNC_PORT" -bg >/tmp/headview-x11vnc.log 2>&1+  fi+  sleep 2+  grep -q "Listening for VNC" /tmp/headview-x11vnc.log || die "x11vnc failed to listen on $VNC_PORT" "check /tmp/headview-x11vnc.log"++  # 5) noVNC web bridge+  start_websockify "$webroot"; sleep 3+  curl -s -o /dev/null --max-time 6 "http://127.0.0.1:$WEB_PORT/vnc.html" || die "noVNC not serving on $WEB_PORT" "check /tmp/headview-web.log"++  # 6) port mapping+  local host; host="$(existing_hostname_for_port "$WEB_PORT")"+  if [ -z "$host" ]; then+    local slug; slug="$(container_slug)"+    [ -n "$slug" ] || die "could not determine this container's slug" "run inside an Adom container; check: adom-cli carbon containers current"+    note "adding port mapping ${WEB_PORT} -> ${PREFIX}-*.adom.cloud"+    local out; out="$(adom-cli carbon containers port-add --port "$WEB_PORT" --prefix "$PREFIX" "$slug" 2>&1)" || die "port-add failed: $out" "check adom-cli auth"+    host="$(printf '%s' "$out" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hostname",""))' 2>/dev/null)"+  fi+  [ -n "$host" ] || die "no mapped hostname" "run: adom-cli carbon containers current  # to inspect mappings"++  # 7) optional app+  if [ -n "$APP" ]; then DISPLAY="$DISPLAY_NUM" nohup $APP >/tmp/headview-app.log 2>&1 & note "launched app: $APP"; fi++  save_state DISPLAY_NUM "$DISPLAY_NUM"+  save_state GEOMETRY "$GEOMETRY"+  save_state VNC_PORT "$VNC_PORT"+  save_state WEB_PORT "$WEB_PORT"+  save_state HV_HOST "$host"++  local url="https://$host/vnc.html?autoconnect=1&resize=scale"+  ok "Container desktop is live and hosted by the container itself."+  note "  URL:      $url"+  [ "$USE_PASSWORD" = "1" ] && note "  Password: $pw"+  note "  Display:  $DISPLAY_NUM (${GEOMETRY})   VNC:$VNC_PORT  web:$WEB_PORT"+  note "Open it in any browser, or as a Hydrogen tab:  headview open"+  note "Launch apps onto it with:  DISPLAY=$DISPLAY_NUM your-app &"+}++cmd_down() {+  load_state+  local removed_map=""+  pkill -f "websockify.*:${WEB_PORT:-6080}" 2>/dev/null+  pkill -f "novnc_proxy" 2>/dev/null+  pkill -f "x11vnc.*:${VNC_PORT:-5900}" 2>/dev/null+  pkill -f "x11vnc -display ${DISPLAY_NUM:-:99}" 2>/dev/null+  pkill -f "fluxbox" 2>/dev/null+  pkill -f "Xvfb ${DISPLAY_NUM:-:99}" 2>/dev/null+  if [ "${1:-}" = "--keep-mapping" ]; then :; else+    if [ -n "${HV_HOST:-}" ]; then+      local slug sub; slug="$(container_slug)"; sub="${HV_HOST%.adom.cloud}"+      if [ -n "$slug" ] && [ -n "$sub" ]; then+        adom-cli carbon containers port-remove "$slug" "$sub" >/dev/null 2>&1 && removed_map=" and removed port mapping ($sub)" || true+      fi+    fi+  fi+  ok "Tore down headview desktop$removed_map."+}++cmd_status() {+  load_state+  local up=0+  printf 'Xvfb:       ' ; pgrep -f "Xvfb ${DISPLAY_NUM:-:99}" >/dev/null && { echo running; up=1; } || echo stopped+  printf 'x11vnc:     ' ; pgrep -f "x11vnc" >/dev/null && echo running || echo stopped+  printf 'websockify: ' ; pgrep -f "websockify|novnc_proxy" >/dev/null && echo running || echo stopped+  if [ -n "${HV_HOST:-}" ]; then note "URL:      https://$HV_HOST/vnc.html?autoconnect=1&resize=scale"; [ -n "${PASSWORD:-}" ] && note "Password: $PASSWORD"; fi+  [ "$up" = 1 ] && ok "headview is up." || ok "headview is not running."+}++cmd_url() {+  load_state+  [ -n "${HV_HOST:-}" ] || die "no desktop is running" "start one with: headview up"+  echo "https://$HV_HOST/vnc.html?autoconnect=1&resize=scale"+}++cmd_password() {+  load_state+  [ -n "${PASSWORD:-}" ] && ok "VNC password: $PASSWORD" || ok "no password set (passwordless mode)"+}++cmd_open() {+  load_state+  [ -n "${HV_HOST:-}" ] || die "no desktop is running" "start one with: headview up"+  [ -n "${VSCODE_PROXY_URI:-}" ] || die "Hydrogen editor not detected (VSCODE_PROXY_URI unset)" "run this from a Hydrogen workspace terminal, or open the URL in a browser: headview url"+  local url="https://$HV_HOST/vnc.html?autoconnect=1&resize=scale"+  adom-cli hydrogen webview open-or-refresh --name "Container Desktop" --url "$url" && ok "Opened 'Container Desktop' webview tab." || die "failed to open webview" "check adom-cli hydrogen webview"+}++cmd_install() {+  local skill_dir="$HOME/.claude/skills/headview"+  mkdir -p "$skill_dir"+  local url="https://wiki.adom.inc/static/apps/headview/SKILL.md"+  if curl -fsSL --max-time 5 "$url" -o "$skill_dir/SKILL.md" 2>/dev/null; then+    ok "Installed headview skill (fetched from wiki)."+  elif [ -f "$(dirname "$0")/SKILL.md" ]; then+    cp "$(dirname "$0")/SKILL.md" "$skill_dir/SKILL.md"; ok "Installed headview skill (bundled copy; wiki unreachable)."+  else+    die "could not install skill" "SKILL.md not found next to the binary and wiki unreachable"+  fi+}++usage() {+  cat <<EOF+headview v$HEADVIEW_VERSION — a browser desktop for a headless Adom container++USAGE: headview <command> [options]++COMMANDS:+  up            Start the desktop (Xvfb+fluxbox+x11vnc+noVNC), map a port, print the URL+  down          Stop everything and remove the port mapping (--keep-mapping to keep it)+  status        Show what's running and the URL/password+  url           Print the desktop URL+  password      Print the VNC password+  open          Open the desktop as a Hydrogen webview tab (needs a Hydrogen terminal)+  install       Install the headview skill into ~/.claude/skills/+  version       Print version++'up' OPTIONS:+  --display :N        X display (default :99)+  --geometry WxH      screen size (default 1600x1000)+  --vnc-port N        internal VNC port (default 5900)+  --web-port N        internal noVNC/web port to map (default 6080)+  --prefix NAME       subdomain prefix for the mapping (default desktop)+  --app "CMD"         launch CMD on the desktop after starting+  --no-password       run passwordless (only do this behind other access control)++EXAMPLES:+  headview up+  headview up --geometry 1920x1080 --app "kicad"+  headview url+  headview down+EOF+}++# ---------------------------------------------------------------- arg parse+cmd="${1:-}"; shift || true+while [ $# -gt 0 ]; do+  case "$1" in+    --display)  DISPLAY_NUM="$2"; shift 2;;+    --geometry) GEOMETRY="$2"; shift 2;;+    --vnc-port) VNC_PORT="$2"; shift 2;;+    --web-port) WEB_PORT="$2"; shift 2;;+    --prefix)   PREFIX="$2"; shift 2;;+    --app)      APP="$2"; shift 2;;+    --no-password) USE_PASSWORD="0"; shift;;+    --keep-mapping) KEEP_MAPPING="--keep-mapping"; shift;;+    *) shift;;+  esac+done++case "$cmd" in+  up)       cmd_up;;+  down)     cmd_down "${KEEP_MAPPING:-}";;+  status)   cmd_status;;+  url)      cmd_url;;+  password) cmd_password;;+  open)     cmd_open;;+  install)  cmd_install;;+  version|--version|-V) ok "headview v$HEADVIEW_VERSION";;+  ""|help|-h|--help) usage;;+  *) err "unknown command: $cmd"; hint "run 'headview help' for usage"; exit 1;;+esac
install.shadded+26
@@ -0,0 +1,26 @@+#!/bin/sh+# headview install — put the CLI on PATH. Skills are placed by the wiki installer.+set -e+SRC="$(cd "$(dirname "$0")" && pwd)"++install_to() {+  cp "$SRC/headview" "$1/headview"+  chmod +x "$1/headview"+  echo "installed headview -> $1/headview"+}++if command -v sudo >/dev/null 2>&1 && sudo -n cp "$SRC/headview" /usr/local/bin/headview 2>/dev/null; then+  sudo chmod +x /usr/local/bin/headview+  echo "installed headview -> /usr/local/bin/headview"+elif [ -w /usr/local/bin ]; then+  install_to /usr/local/bin+else+  mkdir -p "$HOME/.local/bin"+  install_to "$HOME/.local/bin"+  case ":$PATH:" in+    *":$HOME/.local/bin:"*) : ;;+    *) echo "note: add \$HOME/.local/bin to your PATH to run headview" ;;+  esac+fi++echo "run: headview up"
package.jsonadded+60
@@ -0,0 +1,60 @@+{+  "slug": "headview",+  "title": "Headview",+  "type": "app",+  "version": "0.1.0",+  "bundled": true,+  "description": "A browser desktop for a headless Adom container. Runs Xvfb+fluxbox+x11vnc+noVNC inside the container and maps a *.adom.cloud port so any browser (or a Hydrogen webview tab) shows the container's GUI — no SSH tunnel, no local viewer. The container hosts the page itself.",+  "tags": [+    "cli",+    "desktop",+    "vnc",+    "novnc",+    "xvfb",+    "container",+    "gui",+    "remote-desktop"+  ],+  "dependencies": {},+  "discovery_triggers": [+    "headless desktop",+    "container desktop",+    "view a GUI from my container",+    "see kicad in a browser",+    "run a GUI app in the container",+    "novnc desktop",+    "remote desktop for a container",+    "x11 forwarding doesn't work",+    "browser desktop for headless container"+  ],+  "discovery_pitch": "When a headless container needs a real GUI you can open in a browser (X11 forwarding is blocked by the adom.cloud gateway), reach for headview.",+  "sample_prompts": [+    {+      "label": "See a GUI",+      "prompt": "I want to see a GUI app running in my headless container from my laptop"+    },+    {+      "label": "Container desktop",+      "prompt": "give my headless container a desktop I can open in a browser"+    },+    {+      "label": "KiCad in browser",+      "prompt": "open KiCad running in my container in a browser tab"+    }+  ],+  "metadata": {+    "activate": {+      "kind": "skill",+      "skill": "headview",+      "requires": [],+      "verbPrefixes": []+    }+  },+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "hero": {+    "path": "docs/hero.png"+  }+}
uninstall.shadded+12
@@ -0,0 +1,12 @@+#!/bin/sh+# headview uninstall — remove the CLI from PATH.+set -e+for d in /usr/local/bin "$HOME/.local/bin"; do+  if [ -e "$d/headview" ]; then+    if [ -w "$d" ]; then rm -f "$d/headview"+    elif command -v sudo >/dev/null 2>&1; then sudo rm -f "$d/headview"+    fi+    echo "removed $d/headview"+  fi+done+echo "uninstalled headview"