← Commit history
README.md+24−4
@@ -75,8 +75,8 @@ designing a real hero. hero-studio            # serves on port 8846 ``` -Open `/proxy/8846/` in a webview (or `http://localhost:8846` locally). No dependencies,-plain Node.+Open `/proxy/8846/` in a webview (or `http://localhost:8846` locally). The server itself+is plain Node with no dependencies; only Generate needs the renderer runtime below.  Refresh the sample data from the live wiki any time: @@ -84,6 +84,25 @@ Refresh the sample data from the live wiki any time: ./refresh-data.sh      # needs the adom-wiki CLI, then restart the server ``` +## Runtime requirements (Generate)++Generate renders headless Chrome INSIDE the container the server runs in. Light images+(Hydrogen Desktop containers, for example) do not ship any of this, on purpose. What the+render pipeline needs:++- **puppeteer-core** (npm). On Node below 22.12 pin `puppeteer-core@24`; 25.x declares+  `engines: node >=22.12` and can fail at launch rather than at import.+- **A Chrome binary**: `npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer`+  (that `--path` puts it where the renderer scans), or point `CHROME` at any binary.+- **The browser's OS libraries** (libnss3, libgbm1, libasound2t64 on noble, and friends);+  a Chrome binary that exists but cannot start is the classic light-container trap.+- **python3 + PIL** for the export downscale and provenance stamp.++`hero-studio doctor` checks all of it, including that Chrome actually launches, and prints+the exact fix commands for whatever is missing. Install runs it and warns loudly; `pose`+refuses to open the gate for a human until it passes. Whoever installed the package is+already inside the container, so the fixes never require outside access.+ ## Generate  The Generate button (or `POST /generate`) renders the current hero server-side with@@ -91,8 +110,9 @@ Puppeteer to the hero-standard 2000x1250 PNG, pops a toast and a preview modal i open studio, and writes `generated/latest.png`. Each PNG carries a `comment` metadata chunk recording that hero-studio produced it, plus the exact studio state, so publish tooling can verify a hero came through the studio rather than being hand-rolled slop.-The canonical flow for an agent: pose the studio, `POST /generate`, then-`adom-wiki page hero <owner>/<slug> --image generated/latest.png`.+The canonical flow for an agent is the pose gate: `hero-studio pose <owner>/<slug>`,+which opens the studio in front of the human and blocks until THEY click Generate. An+agent never calls `POST /generate` itself; a hero a human did not make should not exist.  ## Drive it (AI-first) 
SKILL.md+31−2
@@ -1,7 +1,6 @@ --- name: hero-studio-description: Open and drive Hero Studio, the hero-image lab with a human gate. Use when a package needs a hero image, when a publish is rejected with HERO_STUDIO_REQUIRED, or when the user wants to design or preview a wiki hero, try layouts, themes, owner brand colors, or see a hero inside the wiki marquee and index grid. Trigger phrases - open hero studio, design a hero, make a hero image, hero playground, pose a hero, preview hero in marquee, hero layouts, hero themes.-user-invocable: true+description: Open and drive Hero Studio, the hero-image lab with a human gate. Use when a package needs a hero image, when a publish is rejected with HERO_STUDIO_REQUIRED, or when the user wants to design or preview a wiki hero, try layouts, themes, owner brand colors, or see a hero inside the wiki marquee and index grid. Trigger phrases - open hero studio, design a hero, make a hero image, hero playground, pose a hero, preview hero in marquee, hero layouts, hero themes, hero studio doctor, hero generate failed, renderer not ready. ---  # Hero Studio@@ -53,6 +52,36 @@ Do not call POST /generate yourself to produce a hero; that bypasses the human a the result should not be published. Publishes of new app/skill heroes are rejected server-side (HERO_STUDIO_REQUIRED) unless the PNG carries the studio provenance stamp. +## Light containers: doctor must pass before the gate opens++Generate renders headless Chrome INSIDE the container the server runs in. Light+images (Hydrogen Desktop containers) ship no browser runtime, so on them a fresh+install cannot render, and `pose` REFUSES to open the gate (RENDERER_NOT_READY)+rather than waste a human review on a Generate that cannot work. The check and+the fix are the same command:++```bash+hero-studio doctor+# verifies: puppeteer-core resolves (and fits this Node), a Chrome binary+# exists, Chrome actually LAUNCHES (catches missing OS libraries, where a+# file-exists test lies), and python3+PIL for the provenance stamp.+# exit 0 ready; exit 1 prints the exact fix commands for each failure.+```++Run the printed fix commands yourself: you are already inside the container+(no SSH, no outside access involved), and install already warned about this at+`pkg install` time. Typical light-container fix: `npm install puppeteer-core@24`+(Node below 22.12 cannot use 25.x), `npx @puppeteer/browsers install+chrome@stable --path ~/.cache/puppeteer`, then the apt library list doctor+prints (note: `libasound2t64` on Ubuntu 24.04). Re-run doctor until it passes,+then pose. pup being able to drive the host browser is irrelevant here: the+render is server-side, in the container.++If `hero-studio vitrine|set|state|toast|reroll` dies with MODULE_NOT_FOUND,+the install is hero-studio 0.10.0, which shipped without drive.mjs; update the+package (`adom-wiki pkg install adom/hero-studio`) or POST to the server+directly (`/vitrine-file` with `{"path": "<abs path>"}`).+ ## Platform-split siblings (twins)  Policy: NO hero inheritance. Every sibling page (adom-desktop-macos and
bin/hero-studio+1
@@ -6,6 +6,7 @@ SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)" case "$1" in   pose) shift; exec node "$DIR/pose.mjs" "$@" ;;+  doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;   vitrine|set|state|toast|reroll) exec node "$DIR/drive.mjs" "$@" ;;   *) exec node "$DIR/server.js" "$@" ;; esac
data/pages.json+13
@@ -1,4 +1,17 @@ [+ {+  "type": "app",+  "title": "US History Jeopardy",+  "author": "",+  "score": 0,+  "stars": 0,+  "installs30d": 0,+  "brief": "Host-run US History trivia: two rounds, daily doubles, Final Jeopardy",+  "tags": [],+  "updated_at": "2026-07-29T18:43:31.630Z",+  "owner": "kcknox",+  "slug": "us-history-jeopardy"+ },  {   "type": "skill",   "title": "Hardware Pipeline",
doctor.mjsadded+176
@@ -0,0 +1,176 @@+#!/usr/bin/env node+// hero-studio doctor: verify the server-side render pipeline actually works,+// BEFORE a human is put in front of the pose gate. Checks that puppeteer-core+// resolves and fits this Node, that a Chrome binary exists, that the binary+// RUNS (a file-exists test passes right past missing shared libraries), and+// that python3+PIL are present for the provenance stamp.+//+// Exit 0 all green, exit 1 with named failures and exact remediation.+// Light containers (Hydrogen Desktop) ship none of this on purpose; the agent+// installing hero-studio there runs the printed commands itself.+import { existsSync, readdirSync, readFileSync } from 'fs';+import { execFile } from 'child_process';+import { homedir } from 'os';+import path from 'path';+import { fileURLToPath } from 'url';++const DIR = path.dirname(fileURLToPath(import.meta.url));++// ---- shared discovery (render-hero.mjs imports these; keep them in sync-proof) ----++export function puppeteerCandidates() {+  return [+    process.env.PUPPETEER_CORE,+    path.join(DIR, 'node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'),+    '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',+    homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',+  ].filter(Boolean);+}++export async function loadPuppeteer() {+  for (const c of puppeteerCandidates()) {+    try { return (await import(c)).default; } catch {}+  }+  try { return (await import('puppeteer-core')).default; } catch {}+  throw new Error('puppeteer-core not found. Set PUPPETEER_CORE to its puppeteer-core.js path.');+}++export function findChrome() {+  if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;+  const base = homedir() + '/.cache/puppeteer/chrome';+  if (existsSync(base)) {+    for (const d of readdirSync(base).sort().reverse()) {+      const p = base + '/' + d + '/chrome-linux64/chrome';+      if (existsSync(p)) return p;+    }+  }+  throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path.');+}++// ---- checks ----++const run = (cmd, args, timeout = 15000) => new Promise((res) => {+  execFile(cmd, args, { timeout }, (err, stdout, stderr) =>+    res({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') }));+});++function puppeteerEngineFit() {+  // puppeteer-core 25.x imports fine on Node 18 and then fails at launch, so+  // the mismatch never surfaces where you would look. Check engines up front.+  for (const c of puppeteerCandidates().map((p) => p.replace(/lib\/puppeteer\/puppeteer-core\.js$/, 'package.json'))) {+    try {+      const pkg = JSON.parse(readFileSync(c, 'utf8'));+      const want = pkg.engines && pkg.engines.node;+      const m = want && want.match(/>=\s*(\d+)\.(\d+)/);+      if (!m) return { fit: true, version: pkg.version };+      const [maj, min] = process.versions.node.split('.').map(Number);+      const fit = maj > +m[1] || (maj === +m[1] && min >= +m[2]);+      return { fit, version: pkg.version, want };+    } catch {}+  }+  // resolved via bare import only; try its package.json through require paths+  return { fit: true, version: null };+}++export async function runChecks() {+  const checks = [];+  const fail = (name, detail, fix) => checks.push({ name, ok: false, detail, fix });+  const pass = (name, detail) => checks.push({ name, ok: true, detail });++  // 1. puppeteer-core resolves. An engines mismatch is a WARNING, not a+  // failure: the launch check below is the ground truth (25.x's engines say+  // node >=22.12 yet it launches fine on some Node 18 images), but when the+  // launch DOES fail on mismatched engines, the pin is the first fix to try.+  let pptr = null;+  let engineWarn = null;+  try {+    pptr = await loadPuppeteer();+    const eng = puppeteerEngineFit();+    if (!eng.fit) engineWarn = `engines want node ${eng.want}; this is ${process.versions.node}`;+    pass('puppeteer-core', 'resolves' + (eng.version ? ` (${eng.version})` : '') + (engineWarn ? `; warning: ${engineWarn}, trusting the launch check` : ''));+  } catch (e) {+    fail('puppeteer-core', e.message,+      `cd ${DIR} && npm install puppeteer-core@24    # 24.x supports Node 18; 25.x wants >=22.12`);+  }++  // 2. a Chrome binary exists+  let chrome = null;+  try {+    chrome = findChrome();+    pass('chrome binary', chrome);+  } catch (e) {+    fail('chrome binary', e.message,+      'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n' ++      '      # --path matters: findChrome() scans ~/.cache/puppeteer/chrome/');+  }++  // 3. the binary actually RUNS. This is the check that catches missing shared+  // libraries; a file-exists test lies on rootfs images never built for a browser.+  let chromeRuns = false;+  if (chrome) {+    const v = await run(chrome, ['--version']);+    if (v.ok) {+      chromeRuns = true;+      pass('chrome runs', v.stdout.trim());+    } else {+      const ldd = await run('ldd', [chrome]);+      const missing = ldd.stdout.split('\n').filter((l) => l.includes('not found'))+        .map((l) => l.trim().split(' ')[0]);+      fail('chrome runs', 'chrome --version failed' + (missing.length ? '; missing libraries: ' + missing.join(', ') : ''),+        'sudo apt-get install -y libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \\\n' ++        '      libdbus-1-3 libcups2 libxkbcommon0 libasound2t64 libgbm1 libpango-1.0-0 libcairo2 \\\n' ++        '      libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libatspi2.0-0 fonts-liberation\n' ++        '      # libasound2t64 is the Ubuntu 24.04 (noble) name; older releases use libasound2');+    }+  }++  // 4. the definitive check: a real headless launch through puppeteer, same+  // args as render-hero.mjs. If this passes, Generate will render.+  if (pptr && chromeRuns) {+    try {+      const browser = await pptr.launch({+        executablePath: chrome, headless: 'new',+        args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],+      });+      await browser.close();+      pass('headless launch', 'puppeteer launched Chrome and closed it cleanly');+    } catch (e) {+      fail('headless launch', String(e && e.message || e).split('\n')[0],+        engineWarn+          ? `cd ${DIR} && npm install puppeteer-core@24    # ${engineWarn}; the pin is the first fix to try`+          : 'read the launch error; check PUPPETEER_CORE/CHROME env overrides and OS libraries');+    }+  }++  // 5. python3 + PIL stamp the provenance metadata after the render+  const pil = await run('python3', ['-c', 'import PIL']);+  if (pil.ok) pass('python3 + PIL', 'provenance stamping ready');+  else fail('python3 + PIL', 'the export downscale + provenance stamp needs Pillow',+    'sudo apt-get install -y python3-pil    # or: pip3 install pillow');++  return { ok: checks.every((c) => c.ok), checks };+}++export function printReport(report, out = console.error) {+  for (const c of report.checks) {+    out((c.ok ? '  ok   ' : '  FAIL ') + c.name + ': ' + c.detail);+    if (!c.ok && c.fix) out('    fix: ' + c.fix);+  }+  if (report.ok) {+    out('renderer ready: Generate will work in this container.');+  } else {+    out('');+    out('RENDERER_NOT_READY: Generate renders headless Chrome INSIDE this container;');+    out('the checks above name what is missing and the exact fix. Run the fix commands');+    out('yourself (you are already in the container; no one needs to SSH anywhere),');+    out('then re-run: hero-studio doctor');+  }+}++// CLI entry: `hero-studio doctor` (also run by install.sh in warn mode)+if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {+  const report = await runChecks();+  printReport(report);+  console.log(JSON.stringify({ ok: report.ok, checks: report.checks }));+  process.exit(report.ok ? 0 : 1);+}
drive.mjsadded+73
@@ -0,0 +1,73 @@+#!/usr/bin/env node+// hero-studio drive verbs: everything an AI pushes into a LIVE studio session.+// The open studio follows within a second (SSE + poll), so the human watches+// the pose change as commands land.+//+//   hero-studio vitrine --file <png> [--full] [--cover]   load a real screenshot+//   hero-studio set '<json>' | key=value [...]            patch posing state+//   hero-studio state                                     print current state+//   hero-studio toast "<text>" [--kind info|success|warn] narrate to the human+//   hero-studio reroll                                    re-roll the circuitry pattern+import path from 'path';++const BASE = 'http://localhost:8846/';+const args = process.argv.slice(2);+const verb = args.shift();++const jf = async (p, body) => {+  const r = await fetch(BASE + p, body === undefined ? undefined : {+    method: 'POST',+    headers: { 'Content-Type': 'application/json' },+    body: JSON.stringify(body),+  });+  const j = await r.json().catch(() => ({}));+  if (!r.ok) throw new Error(p + ' -> ' + r.status);+  return j;+};+const die = (msg) => { console.error(msg); process.exit(1); };++try {+  if (verb === 'vitrine') {+    const fi = args.indexOf('--file');+    if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> [--full] [--cover]');+    const file = path.resolve(args[fi + 1]);+    const out = await jf('vitrine-file', {+      path: file,+      full: args.includes('--full'),+      cover: args.includes('--cover'),+    });+    if (!out.ok) die('ERROR: ' + out.error + (out.error && out.error.includes('cannot read') ? '\n  hint: the studio server reads the path locally; pass an absolute path on this machine' : ''));+    const slot = out.slot ? ' (slot wants ' + out.slot.w + 'x' + out.slot.h + ')' : '';+    console.log(JSON.stringify({ ok: true, bytes: out.bytes, slot: out.slot }));+    console.error('vitrine loaded' + slot + '; the open studio updates within a second');+  } else if (verb === 'set') {+    let patch = {};+    if (args.length === 1 && args[0].trim().startsWith('{')) {+      patch = JSON.parse(args[0]);+    } else if (args.length) {+      for (const kv of args) {+        const [k, v] = kv.split('=');+        if (!k || v === undefined) die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora gradAngle=200');+        patch[k] = /^-?\d+$/.test(v) ? Number(v) : v === 'true' ? true : v === 'false' ? false : v;+      }+    } else {+      die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora gradAngle=200');+    }+    const out = await jf('state', patch);+    console.log(JSON.stringify({ ok: true, applied: patch }));+    void out;+  } else if (verb === 'state') {+    console.log(JSON.stringify(await jf('state'), null, 2));+  } else if (verb === 'toast') {+    const text = args.filter((a) => !a.startsWith('--')).join(' ');+    const ki = args.indexOf('--kind');+    await jf('ui/toast', { text, kind: ki >= 0 ? args[ki + 1] : 'info' });+    console.log('{"ok":true}');+  } else if (verb === 'reroll') {+    console.log(JSON.stringify(await jf('reroll', {})));+  } else {+    die('verbs: pose | vitrine | set | state | toast | reroll');+  }+} catch (e) {+  die('ERROR: ' + e.message + '\n  hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');+}
install.sh+15
@@ -6,3 +6,18 @@ ln -sfn "$PWD/bin/hero-studio" "$HOME/.local/bin/hero-studio" echo "installed: hero-studio" echo "run 'hero-studio' (serves on port 8846), then open /proxy/8846/ in a webview" echo "refresh the sample data with ./refresh-data.sh (needs the adom-wiki CLI)"++# Renderer preflight, warn mode: install stays exit 0 (driving the studio and+# fixing deps later are legitimate on light containers), but a broken renderer+# must be impossible to read as success. Generate spawns headless Chrome INSIDE+# this container; light images (Hydrogen Desktop) do not ship that runtime.+if node "$PWD/doctor.mjs" >/dev/null 2>&1; then+  echo "renderer: OK (headless Chrome + stamping verified; Generate will work)"+else+  echo ""+  echo "!! RENDERER_NOT_READY -- installed, BUT GENERATE WILL FAIL IN THIS CONTAINER !!"+  echo "!! Do NOT open the pose gate for a human yet: run 'hero-studio doctor',    !!"+  echo "!! run the fix commands it prints (you have the shell; no SSH needed),     !!"+  echo "!! and re-run it until it passes. pose refuses to open until then.         !!"+  node "$PWD/doctor.mjs" 2>&1 | sed 's/^/   /' || true+fi
package.json+10−4
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.10.0",+  "version": "0.10.1",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [@@ -40,7 +40,11 @@     "import a hero",     "re-stamp a hero",     "verify a hero",-    "vitrine"+    "vitrine",+    "hero studio doctor",+    "hero generate failed",+    "renderer not ready",+    "hero studio light container"   ],   "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.",   "sample_prompts": [@@ -74,14 +78,16 @@     "refresh-data.sh",     "server.js",     "pose.mjs",+    "drive.mjs",+    "doctor.mjs",     "bin/hero-studio",     "public/**",     "data/**",-    "docs/hero.png",     "docs/contexts.png",     "docs/themes.png",     "docs/index-cards.png",     "docs/component-mode.png",-    "render-hero.mjs"+    "render-hero.mjs",+    "screenshots/hero.png"   ] }
page.json+10−4
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.10.0",+  "version": "0.10.1",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [@@ -40,7 +40,11 @@     "import a hero",     "re-stamp a hero",     "verify a hero",-    "vitrine"+    "vitrine",+    "hero studio doctor",+    "hero generate failed",+    "renderer not ready",+    "hero studio light container"   ],   "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.",   "sample_prompts": [@@ -75,15 +79,17 @@     "refresh-data.sh",     "server.js",     "pose.mjs",+    "drive.mjs",+    "doctor.mjs",     "bin/hero-studio",     "public/**",     "data/**",-    "docs/hero.png",     "docs/contexts.png",     "docs/themes.png",     "docs/index-cards.png",     "docs/component-mode.png",-    "render-hero.mjs"+    "render-hero.mjs",+    "screenshots/hero.png"   ],   "org": "adom",   "author": {
pose.mjs+30−7
@@ -11,6 +11,7 @@ import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path'; import fs from 'fs';+import { runChecks, printReport } from './doctor.mjs';  const DIR = path.dirname(fileURLToPath(import.meta.url)); const BASE = 'http://localhost:8846/';@@ -38,13 +39,27 @@ if (surface !== 'webview' && surface !== 'pup') { const [owner, slug] = ref.split('/'); const timeoutSecs = Number(flags.timeout || 900); +// 0. PREFLIGHT: prove the renderer works BEFORE a human is summoned. Without+// this, the human walks the full checklist, clicks Generate, and only then+// learns the container cannot render (light Hydrogen Desktop images ship no+// headless-Chrome runtime). That wasted review is the worst failure this tool+// can produce, so the gate refuses to open over a dead renderer.+const preflight = await runChecks();+if (!preflight.ok) {+  console.error('ERROR [RENDERER_NOT_READY]: refusing to open the pose gate.');+  printReport(preflight);+  console.error('Fix the renderer FIRST, then re-run pose. Do not open the studio for the');+  console.error('human until `hero-studio doctor` passes.');+  process.exit(1);+}+ const jf = async (p, opts) => {   const r = await fetch(BASE + p, opts);   if (!r.ok) throw new Error(p + ' -> ' + r.status);   return r.json(); };-const sh = (cmd, a) => new Promise((res) => {-  execFile(cmd, a, { timeout: 20000 }, (err, stdout) => res(err ? null : stdout));+const sh = (cmd, a, timeout = 20000) => new Promise((res) => {+  execFile(cmd, a, { timeout }, (err, stdout) => res(err ? null : stdout)); });  // 1. ensure the server is up@@ -95,17 +110,25 @@ if (surface === 'pup') {   // spawn a duplicate window (browser_status first, then navigate-or-open).   const status = await sh('adom-desktop', ['browser_status', '{}']);   const hasSession = !!(status && status.includes('hero-studio'));-  const opened = hasSession+  let opened = hasSession     ? await sh('adom-desktop', ['browser_navigate', JSON.stringify({ sessionId: 'hero-studio', url: proxyBase })])-    : await sh('adom-desktop', ['browser_open_window', JSON.stringify({ sessionId: 'hero-studio', profile: 'hero-studio', url: proxyBase })]);+    : await sh('adom-desktop', ['browser_open_window', JSON.stringify({ sessionId: 'hero-studio', profile: 'hero-studio', url: proxyBase })], 60000);+  if (opened === null && !hasSession) {+    // A first open on a cold profile can outlive its exec timeout while the+    // window is still coming up; poll for the session before calling desktop+    // dead, or a slow start gets misread as PUP_UNREACHABLE.+    for (let i = 0; i < 6 && opened === null; i++) {+      await new Promise((r) => setTimeout(r, 5000));+      const st = await sh('adom-desktop', ['browser_status', '{}']);+      if (st && st.includes('hero-studio')) opened = st;+    }+  }   if (opened === null) {     console.error('WARN [PUP_UNREACHABLE]: Adom Desktop did not answer; falling back to a webview tab so the gate still reaches the human.');+    await openWebview();   } else {     await sh('adom-desktop', ['browser_alert_window', JSON.stringify({ sessionId: 'hero-studio' })]);   }-  if (opened === null) {-    await openWebview();-  } } else {   await openWebview(); }
render-hero.mjs+4−29
@@ -1,36 +1,11 @@ // Renders the studio's ?render=1 view to a PNG. Usage: node render-hero.mjs <url> <out.png>-// Needs puppeteer-core (PUPPETEER_CORE env or a known location) and Chrome (CHROME env or-// the puppeteer cache). Exits nonzero with a plain message when either is missing.-import { existsSync, readdirSync } from 'fs';-import { homedir } from 'os';+// Discovery of puppeteer-core and Chrome lives in doctor.mjs (one source of+// truth; `hero-studio doctor` preflights the same paths this render uses).+// Exits nonzero with a plain message when either is missing.+import { loadPuppeteer, findChrome } from './doctor.mjs';  const [,, url, out] = process.argv; -async function loadPuppeteer() {-  const candidates = [-    process.env.PUPPETEER_CORE,-    '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',-    homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',-  ].filter(Boolean);-  for (const c of candidates) {-    try { return (await import(c)).default; } catch {}-  }-  try { return (await import('puppeteer-core')).default; } catch {}-  throw new Error('puppeteer-core not found. Set PUPPETEER_CORE to its puppeteer-core.js path.');-}--function findChrome() {-  if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;-  const base = homedir() + '/.cache/puppeteer/chrome';-  if (existsSync(base)) {-    for (const d of readdirSync(base).sort().reverse()) {-      const p = base + '/' + d + '/chrome-linux64/chrome';-      if (existsSync(p)) return p;-    }-  }-  throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path.');-}- const puppeteer = await loadPuppeteer(); const browser = await puppeteer.launch({   executablePath: findChrome(), headless: 'new',
server.js+1−1
@@ -251,7 +251,7 @@ const server = http.createServer((req, res) => {       clearTimeout(timer);       if (code !== 0 || !fs.existsSync(outFile)) {         logLine('error', 'generate failed: ' + errBuf.slice(-400));-        return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: 'set CHROME and PUPPETEER_CORE env vars for the server process' });+        return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: "run 'hero-studio doctor': it verifies puppeteer-core, Chrome (including that the binary RUNS), and the PIL stamp step, and prints the exact fix for whatever is missing" });       }       // downscale to the hero standard 2000x1250 and stamp provenance metadata       const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined } });