← Commit history
SKILL.md+33−1
@@ -171,7 +171,7 @@ gate, which opens the studio in front of the human and BLOCKS until they click Generate:  ```bash-hero-studio pose <owner>/<slug> --surface webview|pup [--title "T"] [--brief "one-liner"] [--type app,service] [--headline "H"] [--timeout 900] [--port N]+hero-studio pose <owner>/<slug> --surface webview|pup|browser [--title "T"] [--brief "one-liner"] [--type app,service] [--headline "H"] [--timeout 900] [--port N] # blocks; exits 0 with {file, path, port, latest} JSON on stdout when the HUMAN generates, # exit 2 on timeout, exit 1 on setup errors, exit 3 when the pup surface failed # (the reason is printed; there is NO silent webview fallback)@@ -223,6 +223,38 @@ 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. +## On a desktop (Windows, macOS, Linux without an Adom container)++The studio runs on a plain desktop too. What changes:++- **Surface.** Use `--surface browser`: pose opens `http://127.0.0.1:<port>/` in+  the default browser (`start`, `open` or `xdg-open`) and prints the URL either+  way, then blocks for the human's Generate exactly as in a container. Without+  `VSCODE_PROXY_URI`, `webview` and `pup` refuse with NO_CONTAINER_PROXY.+- **Identity.** Wiki calls read as the first identity found, in the adom-wiki+  CLI's order: `ADOM_WIKI_TOKEN`, the adom-wiki stored token, the container key,+  the adom-cli login, then the Adom Bridge session in `~/.adom/session.json`.+  With none, pose prints WIKI_ANONYMOUS and the Brand step says so: private+  guides such as adom/brand look private or missing until you sign in.+- **Renderer.** Chrome is found in the puppeteer cache for any OS or at its+  standard install (Program Files, LOCALAPPDATA, /Applications); `CHROME`+  overrides. On Windows and macOS the headless launch is the check, not+  `chrome --version`. The pup leg reads `opt` (not used on a desktop).+- **Provenance stamp.** Needs Python with Pillow: `python3`, `python`, or+  `py -3` (on Windows `python3` is usually the Store alias stub), else+  ImageMagick 7 (`magick`; `convert` is never run on Windows). With neither,+  Generate fails with STAMP_FAILED and the fix, instead of keeping a PNG the+  registry would refuse.+- **Windows install.** install.sh runs under Git Bash: it hands Node native+  paths, writes `~/.local/bin/hero-studio` as a wrapper plus `hero-studio.cmd`+  for PowerShell and cmd, and reports a failed renderer step instead of+  stopping silently. Instance state lives under the real home directory, so+  a restart from PowerShell finds the pose saved from Git Bash.++`npm run test:platform` covers the identity order, Windows import paths, Chrome+locations per OS, a server in a desktop-only environment, and the STAMP_FAILED+refusal.+ ## Light containers: doctor must pass before the gate opens  Generate renders headless Chrome INSIDE the container the server runs in, and
bin/hero-studio+10
@@ -4,6 +4,16 @@ # studio in front of the human and blocks until they click Generate. SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)"+# A launcher COPIED out of the package (Git Bash `ln -s` without native+# symlinks) resolves to ~/.local; refuse to guess and say how to repair it.+if [ ! -f "$DIR/server.js" ] && [ -n "$HERO_STUDIO_HOME" ]; then DIR="$HERO_STUDIO_HOME"; fi+if [ ! -f "$DIR/server.js" ]; then+  echo "hero-studio: cannot find the package from $SELF (no server.js in $DIR)." >&2+  echo "  Re-run the package's install.sh, or set HERO_STUDIO_HOME to the package directory." >&2+  exit 1+fi+# Windows Node cannot open Git Bash's /c/... paths; hand it a native one.+DIR="$(cygpath -m "$DIR" 2>/dev/null || echo "$DIR")" if [ "$1" = "--ai-thread" ]; then   export ADOM_AI_THREAD="$2"   shift 2
data/pages.json+1−1
@@ -342,7 +342,7 @@   "updated_at": "2026-07-17T15:21:36.819Z",   "owner": "adom",   "slug": "adom-wiki-cli",-  "aiThread": "cli-thread hero refresh",+  "aiThread": "desk-test3",   "org": "adom",   "live": true  },
doctor.mjs+55−20
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'fs'; import { execFile } from 'child_process'; import { homedir } from 'os'; import path from 'path';-import { fileURLToPath } from 'url';+import { fileURLToPath, pathToFileURL } from 'url';  const DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -20,14 +20,25 @@ const DIR = path.dirname(fileURLToPath(import.meta.url)); // render-hero.mjs and server.js read the same file ---- import { createRequire } from 'module'; const RP = createRequire(import.meta.url)('./renderer-paths.cjs');+const PLAT = createRequire(import.meta.url)('./platform.cjs'); export const puppeteerCandidates = RP.puppeteerCandidates; export const puppeteerInstallCommand = RP.puppeteerInstallCommand; export const quickRendererStatus = RP.quickStatus; +// import() of a bare absolute path fails on Windows (ERR_UNSUPPORTED_ESM_URL_SCHEME:+// "C:" reads as a URL scheme), and the old empty catch turned that into+// "puppeteer-core not found" while the files were right there. Anything that+// is not already a URL goes through pathToFileURL.+export function importSpecifier(p) {+  return /^(file|data|node):/i.test(p) ? p : pathToFileURL(p).href;+}+ export async function loadPuppeteer() {+  const errors = [];   for (const c of puppeteerCandidates()) {-    try { return (await import(c)).default; } catch {}+    try { return (await import(importSpecifier(c))).default; } catch (e) { errors.push(c + ': ' + (e && e.message || e)); }   }+  if (errors.length && process.env.HERO_STUDIO_DEBUG) console.error('puppeteer-core candidates failed:\n  ' + errors.join('\n  '));   if (!RP.ISOLATED) { try { return (await import('puppeteer-core')).default; } catch {} }   throw new Error('puppeteer-core not found. Fix: ' + RP.puppeteerInstallCommand()); }@@ -91,13 +102,19 @@ export async function runChecks() {   } catch (e) {     fail('chrome binary', e.message,       'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n' +-      '      # --path matters: findChrome() scans ~/.cache/puppeteer/chrome/');+      '      # --path matters: findChrome() scans ~/.cache/puppeteer/chrome/' + (PLAT.IS_WIN || PLAT.IS_MAC ? '\n    or: ' + PLAT.fixFor('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) {+  // Windows Chrome prints nothing for --version and macOS's is a GUI app, so+  // there the probe proves nothing and used to block the real check below.+  // The headless launch IS the check on those platforms.+  if (chrome && (PLAT.IS_WIN || PLAT.IS_MAC)) {+    chromeRuns = true;+    pass('chrome binary present', 'the headless launch below is the real check on ' + process.platform);+  } else if (chrome) {     const v = await run(chrome, ['--version']);     if (v.ok) {       chromeRuns = true;@@ -140,6 +157,13 @@ export async function runChecks() {   // desktop relay answering plus a proxy URL the host can reach the studio on.   let pupOk = false;   const proxied = !!process.env.VSCODE_PROXY_URI;+  // On a desktop there is no container and no host to fall back to: the+  // local renderer already runs on this machine. The leg is reported as not+  // applicable rather than probed (and never as a failure).+  if (!PLAT.inContainer()) {+    checks.push({ name: 'pup fallback', ok: false, optional: true,+      detail: 'not used on a desktop: Generate renders with the local Chrome on this machine' });+  } else {   // Post-rename boxes answer via adom-bridge-cli with pup_* verbs (#31); the   // adapter resolves whichever generation this box has.   const { makeBridge } = await import('./bridge.mjs');@@ -151,18 +175,20 @@ export async function runChecks() {     pass('pup fallback', bridge.generation + ' answers (' + bridge.verbs.status + '); the host browser can render');   } else {     fail('pup fallback', !adAnswered-      ? 'the desktop bridge did not answer (' + bridge.generation + ', ' + bridge.verbs.status + '); no host relay from this container'+      ? 'the desktop bridge did not answer (' + bridge.generation + ', ' + bridge.verbs.status + '); no host relay from ' + PLAT.where()       : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio',       'optional when the local renderer works; otherwise start the desktop bridge app on the host and retry');+    checks[checks.length - 1].optional = true;+  }   }    // 6. python3 + PIL stamp the provenance metadata after the render (and crop   // the pup screenshot); required on BOTH render paths-  const pil = await run('python3', ['-c', 'import PIL']);-  const pilOk = pil.ok;-  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');+  const py = PLAT.pythonWithPillow({ fresh: true });+  const pilOk = !!py;+  if (py) pass('python + PIL', 'provenance stamping ready (' + py.label + ')');+  else fail('python + PIL', 'the export downscale + provenance stamp needs Python with Pillow (tried ' + (PLAT.IS_WIN ? 'python, py -3, python3' : 'python3, python') + ')',+    PLAT.fixFor('pillow'));    const renderer = localOk ? 'local' : (pupOk ? 'pup' : null);   return { ok: pilOk && !!renderer, renderer, checks };@@ -180,7 +206,7 @@ export async function runBrandChecks(owner) {   const pass = (name, detail) => checks.push({ name, ok: true, detail });   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = {};-  try { headers.Authorization = 'Bearer ' + readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());    let guide = null;   try {@@ -190,7 +216,7 @@ export async function runBrandChecks(owner) {       guide = { owner, status: 'missing', hints: body.hints || [] };     } else if (!r.ok) {       fail('brand guide', 'the registry answered HTTP ' + r.status + ' for ' + owner,-        'check ADOM_WIKI_REGISTRY (' + REG + ') and this container\'s API key; until it answers, the hero holds rather than wearing Adom\'s look');+        'check ADOM_WIKI_REGISTRY (' + REG + ') and the wiki identity (' + PLAT.wikiIdentity().source + '); until it answers, the hero holds rather than wearing Adom\'s look');       return { ok: false, owner, status: 'unavailable', guide: null, checks };     } else {       guide = body;@@ -270,23 +296,32 @@ export function printReport(report, out = console.error) {     // a failed check that the chosen render path does not need is a miss, not     // a failure: with the local renderer green, a silent pup relay is fine,     // and with pup green, the local Chrome stack is optional fidelity.-    out((c.ok ? '  ok   ' : (report.ok ? '  miss ' : '  FAIL ')) + c.name + ': ' + c.detail);-    if (!c.ok && c.fix) out('    fix: ' + c.fix);+    // An optional leg (pup) is only a failure when it was the last path left+    // and the report failed; otherwise it reads as optional, not as a FAIL.+    const tag = c.ok ? '  ok   ' : (report.ok || c.optional && !PLAT.inContainer() ? (c.optional ? '  opt  ' : '  miss ') : '  FAIL ');+    out(tag + c.name + ': ' + c.detail);+    if (!c.ok && c.fix && !(c.optional && report.ok)) out('    fix: ' + c.fix);   }   if (report.ok) {     if (report.renderer === 'pup') {       out('renderer ready via pup fallback: the HOST browser will render Generate.');       out('Full fidelity comes from the local runtime; the fixes above get you there.');     } else {-      out('renderer ready (local): Generate will work in this container.');+      out('renderer ready (local): Generate will work ' + PLAT.whereIn() + '.');     }   } else {     out('');-    out('RENDERER_NOT_READY: Generate renders headless Chrome INSIDE this container,');-    out('or falls back to the host browser through Adom Desktop; neither path works');-    out('here. The checks above name what is missing and the exact fix. Run the fix');-    out('commands yourself (you are already in the container; no one needs to SSH');-    out('anywhere), then re-run: hero-studio doctor');+    if (PLAT.inContainer()) {+      out('RENDERER_NOT_READY: Generate renders headless Chrome INSIDE this container,');+      out('or falls back to the host browser through Adom Desktop; neither path works');+      out('here. The checks above name what is missing and the exact fix. Run the fix');+      out('commands yourself (you are already in the container; no one needs to SSH');+      out('anywhere), then re-run: hero-studio doctor');+    } else {+      out('RENDERER_NOT_READY: Generate renders headless Chrome on this machine, and');+      out('that does not work yet. The checks above name what is missing and the exact');+      out('fix; apply them, then re-run: hero-studio doctor');+    }   } } 
install.sh+41−14
@@ -1,26 +1,53 @@ #!/bin/sh set -e+# Windows (Git Bash / MSYS / Cygwin): Node cannot resolve Git Bash's /c/...+# paths, and `ln -s` makes a COPY unless MSYS=winsymlinks:nativestrict, which+# leaves a launcher that cannot find its package (hero-studio#62). Node gets a+# native path (cygpath -m), and the launcher is a wrapper, not a symlink.+case "$(uname -s 2>/dev/null)" in+  MINGW*|MSYS*|CYGWIN*) ON_WINDOWS=1 ;;+  *) ON_WINDOWS= ;;+esac+PKG="$(cygpath -m "$PWD" 2>/dev/null || echo "$PWD")"+ mkdir -p "$HOME/.local/bin" chmod +x "$PWD/bin/hero-studio"-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"+if [ -n "$ON_WINDOWS" ]; then+  rm -f "$HOME/.local/bin/hero-studio"+  printf '#!/bin/sh\nexec "%s/bin/hero-studio" "$@"\n' "$PWD" > "$HOME/.local/bin/hero-studio"+  chmod +x "$HOME/.local/bin/hero-studio"+  # PowerShell and cmd: run the same launcher through Git's bash.+  WINBIN="$(cygpath -w "$PWD/bin/hero-studio" 2>/dev/null || echo "$PWD/bin/hero-studio")"+  printf '@echo off\r\nwhere bash >nul 2>nul && (bash "%s" %%*) || ("%%ProgramFiles%%\\Git\\bin\\bash.exe" "%s" %%*)\r\n' "$WINBIN" "$WINBIN" > "$HOME/.local/bin/hero-studio.cmd"+  echo "installed: hero-studio (wrapper + hero-studio.cmd in ~/.local/bin; add it to PATH for PowerShell)"+  echo "run 'hero-studio', then open http://127.0.0.1:8846/ in a browser (pose: --surface browser)"+else+  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"+fi echo "refresh the sample data with ./refresh-data.sh (needs the adom-wiki CLI)"  # hero-studio#58: a package update replaces this directory, node_modules-# included, so puppeteer-core lives OUTSIDE it (~/.cache/hero-studio) and an-# update cannot take the renderer away from a running studio. Installed here-# when nothing resolves; the pin follows this Node (24.x below 22.12).-RH="$HOME/.cache/hero-studio"-if node -e "process.exit(require('$PWD/renderer-paths.cjs').puppeteerPath() ? 0 : 1)" 2>/dev/null; then+# included, so puppeteer-core lives OUTSIDE it and an update cannot take the+# renderer away from a running studio. The directory comes from Node itself+# (renderer-paths.cjs), so it is the same one the studio reads on every OS.+# A failure here is reported, never silent, and never ends the script early.+RENDERER_OK=+if node -e "process.exit(require('$PKG/renderer-paths.cjs').puppeteerPath() ? 0 : 1)" 2>/dev/null; then   echo "renderer dependency: puppeteer-core resolves"+  RENDERER_OK=1 else-  PIN="$(node -e "console.log(require('$PWD/renderer-paths.cjs').puppeteerPin())")"-  mkdir -p "$RH"-  if command -v npm >/dev/null 2>&1 && npm install --prefix "$RH" --no-audit --no-fund --loglevel=error "puppeteer-core@$PIN" >/dev/null 2>&1; then+  RH="$(node -e "console.log(require('$PKG/renderer-paths.cjs').RENDERER_HOME)" 2>/dev/null || true)"+  PIN="$(node -e "console.log(require('$PKG/renderer-paths.cjs').puppeteerPin())" 2>/dev/null || echo 24)"+  if [ -z "$RH" ]; then+    echo "!! renderer dependency: FAILED: node could not load $PKG/renderer-paths.cjs, so puppeteer-core was not installed."+    echo "!!   run 'hero-studio doctor' for the exact fix."+  elif command -v npm >/dev/null 2>&1 && npm install --prefix "$RH" --no-audit --no-fund --loglevel=error "puppeteer-core@$PIN" >/dev/null 2>&1; then     echo "renderer dependency: installed puppeteer-core@$PIN into $RH (outside the package dir; updates keep it)"+    RENDERER_OK=1   else-    echo "!! renderer dependency: could not install puppeteer-core@$PIN; run: npm install --prefix $RH puppeteer-core@$PIN"+    echo "!! renderer dependency: FAILED: could not install puppeteer-core@$PIN. Run: npm install --prefix \"$RH\" puppeteer-core@$PIN"   fi fi @@ -29,7 +56,7 @@ fi # say which renderer actually verified. "OK" while local Chrome was missing is # how hero-studio#8 happened: the pup fallback carried a fresh install further # than its fidelity deserved, silently.-DOCTOR_JSON="$(node "$PWD/doctor.mjs" 2>/dev/null || true)"+DOCTOR_JSON="$(node "$PKG/doctor.mjs" 2>/dev/null || true)" case "$DOCTOR_JSON" in   *'"renderer":"local"'*)     echo "renderer: OK (local headless Chrome verified; Generate renders in-container)" ;;@@ -45,5 +72,5 @@ case "$DOCTOR_JSON" in     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 ;;+    node "$PKG/doctor.mjs" 2>&1 | sed 's/^/   /' || true ;; esac
package.json+5−3
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.21.5",+  "version": "0.22.0",   "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. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: 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, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase 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": [@@ -68,8 +68,9 @@     "test:render": "node test/render-view.mjs",     "test:renderer": "node test/renderer.mjs",     "test:syntax": "node test/syntax.mjs",-    "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs",-    "prepublishOnly": "node test/syntax.mjs"+    "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs",+    "prepublishOnly": "node test/syntax.mjs",+    "test:platform": "node test/platform.mjs"   },   "needs_sudo": false,   "hero": {@@ -87,6 +88,7 @@     "drive.mjs",     "doctor.mjs",     "renderer-paths.cjs",+    "platform.cjs",     "bridge.mjs",     "capture.mjs",     "bin/hero-studio",
page.json+5−3
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.21.5",+  "version": "0.22.0",   "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. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: 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, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase 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": [@@ -68,8 +68,9 @@     "test:render": "node test/render-view.mjs",     "test:renderer": "node test/renderer.mjs",     "test:syntax": "node test/syntax.mjs",-    "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs",-    "prepublishOnly": "node test/syntax.mjs"+    "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs",+    "prepublishOnly": "node test/syntax.mjs",+    "test:platform": "node test/platform.mjs"   },   "needs_sudo": false,   "hero": {@@ -88,6 +89,7 @@     "drive.mjs",     "doctor.mjs",     "renderer-paths.cjs",+    "platform.cjs",     "bridge.mjs",     "capture.mjs",     "bin/hero-studio",
platform.cjsadded+151
@@ -0,0 +1,151 @@+// Where Hero Studio is running, answered once for every module (server.js,+// doctor.mjs, pose.mjs, render-pup.mjs, install.sh). The studio was born in+// Adom containers and assumed them: a token file at /var/run/adom/api-key,+// HOME set, python3 on PATH, a VS Code proxy URL. On a Windows or macOS+// desktop none of that holds, and each assumption failed silently in its own+// place. Everything that depends on the host asks here instead.+const fs = require('fs');+const os = require('os');+const path = require('path');+const { execFileSync } = require('child_process');++const IS_WIN = process.platform === 'win32';+const IS_MAC = process.platform === 'darwin';+// Overridable for tests only: this box HAS a container key, and the identity+// order below must be testable without it.+const CONTAINER_TOKEN_FILE = process.env.HERO_STUDIO_CONTAINER_KEY || '/var/run/adom/api-key';++/// The home directory. os.homedir() reads USERPROFILE on Windows, so it is+/// right whichever shell started the process (PowerShell sets no HOME).+function homeDir() {+  return os.homedir();+}++/// Inside an Adom container: the container key exists, or the VS Code proxy+/// is set. Wording ("this container" vs "this machine") and the fix lines+/// branch on it.+function inContainer() {+  return !!process.env.VSCODE_PROXY_URI || fs.existsSync(CONTAINER_TOKEN_FILE);+}++function where() {+  return inContainer() ? 'this container' : 'this machine';+}++/// The same, with its preposition: "in this container" / "on this machine".+function whereIn() {+  return inContainer() ? 'in this container' : 'on this machine';+}++/// The per-user config directory, the same one the adom-wiki and adom-cli+/// CLIs use (Rust dirs::config_dir): %APPDATA% on Windows,+/// ~/Library/Application Support on macOS, $XDG_CONFIG_HOME or ~/.config.+function configDir() {+  if (IS_WIN) return process.env.APPDATA || path.join(homeDir(), 'AppData', 'Roaming');+  if (IS_MAC) return path.join(homeDir(), 'Library', 'Application Support');+  return process.env.XDG_CONFIG_HOME || path.join(homeDir(), '.config');+}++function readJsonField(file, field) {+  try {+    const v = JSON.parse(fs.readFileSync(file, 'utf8'));+    const t = v && typeof v[field] === 'string' ? v[field].trim() : '';+    return t || null;+  } catch { return null; }+}++function readTrimmed(file) {+  try { const t = fs.readFileSync(file, 'utf8').trim(); return t || null; } catch { return null; }+}++/// The wiki identity to read as, first match wins, in the adom-wiki CLI's+/// order: env ADOM_WIKI_TOKEN / ADOMPKG_TOKEN, the wiki CLI's stored token,+/// the container key, the adom-cli login, then the desktop session that Adom+/// Bridge writes to ~/.adom/session.json. Re-read on every call (cheap), so+/// a sign-in after the server started is picked up without a restart.+function wikiIdentity() {+  const env = (k) => (process.env[k] || '').trim() || null;+  const tries = [+    ['env:ADOM_WIKI_TOKEN', () => env('ADOM_WIKI_TOKEN')],+    ['env:ADOMPKG_TOKEN', () => env('ADOMPKG_TOKEN')],+    ['adom-wiki config', () => readJsonField(path.join(configDir(), 'adom-wiki', 'config.json'), 'api_token')],+    ['container key', () => readTrimmed(CONTAINER_TOKEN_FILE)],+    ['adom-cli login', () => readJsonField(path.join(configDir(), 'adom-cli', 'config.json'), 'api_token')],+    ['desktop session (~/.adom/session.json)', () => readJsonField(path.join(homeDir(), '.adom', 'session.json'), 'session_token')],+  ];+  for (const [source, get] of tries) {+    const token = get();+    if (token) return { token, source, anonymous: false };+  }+  return { token: null, source: 'none', anonymous: true };+}++/// { Authorization } for a wiki call, or {} when there is no identity.+function authHeader() {+  const id = wikiIdentity();+  return id.token ? { Authorization: 'Bearer ' + id.token } : {};+}++/// The interpreter that can run Pillow, probed once and cached: python3,+/// then python, then the Windows launcher `py -3`. On Windows `python3` is+/// usually the Microsoft Store alias stub, so it is tried last there.+/// Returns { cmd, args } (args to put before -c), or null.+let pythonCache;+function pythonWithPillow({ fresh = false } = {}) {+  if (pythonCache !== undefined && !fresh) return pythonCache;+  const order = IS_WIN+    ? [['python', []], ['py', ['-3']], ['python3', []]]+    : [['python3', []], ['python', []]];+  pythonCache = null;+  for (const [cmd, pre] of order) {+    try {+      execFileSync(cmd, [...pre, '-c', 'import PIL'], { stdio: 'ignore', timeout: 15000, windowsHide: true });+      pythonCache = { cmd, args: pre, label: [cmd, ...pre].join(' ') };+      break;+    } catch { /* next */ }+  }+  return pythonCache;+}++/// ImageMagick for the no-Pillow fallback. Never `convert` on Windows: that+/// name is the FAT-to-NTFS volume converter in System32. ImageMagick 7 is+/// `magick` everywhere.+function imageMagick() {+  const tries = IS_WIN ? ['magick'] : ['magick', 'convert'];+  for (const cmd of tries) {+    try {+      execFileSync(cmd, ['-version'], { stdio: 'ignore', timeout: 10000, windowsHide: true });+      return cmd;+    } catch { /* next */ }+  }+  return null;+}++/// Platform-aware fix lines for doctor and the gate.+function fixFor(what) {+  if (what === 'pillow') {+    if (IS_WIN) return 'py -m pip install pillow    # or: python -m pip install pillow (python.org Python, not the Microsoft Store alias)';+    if (IS_MAC) return 'python3 -m pip install pillow';+    return 'sudo apt-get install -y python3-pil    # or: pip3 install pillow';+  }+  if (what === 'chrome') {+    if (IS_WIN) return 'install Google Chrome, or set CHROME to a chrome.exe (e.g. C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe)';+    if (IS_MAC) return 'install Google Chrome, or set CHROME to "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"';+    return 'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer';+  }+  return '';+}++/// The command that opens a URL in the default browser.+function openUrlCommand(url) {+  if (IS_WIN) return { cmd: 'cmd', args: ['/c', 'start', '""', url] };+  if (IS_MAC) return { cmd: 'open', args: [url] };+  return { cmd: 'xdg-open', args: [url] };+}++module.exports = {+  IS_WIN, IS_MAC, CONTAINER_TOKEN_FILE,+  homeDir, inContainer, where, whereIn, configDir,+  wikiIdentity, authHeader,+  pythonWithPillow, imageMagick, fixFor, openUrlCommand,+};
pose.mjs+40−6
@@ -13,6 +13,7 @@ import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path';+import os from 'os'; import fs from 'fs'; import { runChecks, printReport, hintLine } from './doctor.mjs'; import { makeBridge } from './bridge.mjs';@@ -49,12 +50,13 @@ if (!AI_THREAD) { process.env.ADOM_AI_THREAD = AI_THREAD; const ref = pos[0]; if (!ref || !ref.includes('/')) {-  console.error('usage: hero-studio pose <owner/slug> --surface webview|pup [--title T] [--brief B] [--type app|skill] [--timeout SECS]');+  console.error('usage: hero-studio pose <owner/slug> --surface webview|pup|browser [--title T] [--brief B] [--type app|skill] [--timeout SECS]');   process.exit(1); } const surface = flags.surface;-if (surface !== 'webview' && surface !== 'pup') {-  console.error('ERROR [SURFACE_REQUIRED]: say which surface the human is looking at: --surface webview | --surface pup');+if (surface !== 'webview' && surface !== 'pup' && surface !== 'browser') {+  console.error('ERROR [SURFACE_REQUIRED]: say which surface the human is looking at: --surface webview | --surface pup | --surface browser');+  console.error('  browser = the system default browser on this machine (a desktop with no Adom container); webview/pup are container surfaces.');   console.error("  why: opening the wrong surface means the human never sees the gate. Check their stored preference first:");   console.error("    adom-cli hydrogen probe        # -> { window_mode: \"webview\" | \"pup\" }");   console.error('  then pass that value. Never open both surfaces for the same content.');@@ -76,6 +78,18 @@ if (!preflight.ok) {   console.error('human until `hero-studio doctor` passes.');   process.exit(1); }+{+  const PLATI = (await import('module')).createRequire(import.meta.url)('./platform.cjs');+  const id = PLATI.wikiIdentity();+  if (id.anonymous) {+    console.error('WARNING [WIKI_ANONYMOUS]: no wiki identity found (ADOM_WIKI_TOKEN, adom-wiki or adom-cli login, the container key, or');+    console.error('  an Adom Bridge session in ~/.adom/session.json), so the studio reads the wiki anonymously: private pages and');+    console.error('  private brand guides (adom/brand is one) will look private or missing, and Generate can lock on them.');+    console.error('  Sign in first: adom-wiki auth login, or set ADOM_WIKI_TOKEN, then re-run pose.');+  } else {+    console.error('wiki identity: ' + id.source);+  }+} if (preflight.renderer === 'pup') {   console.error('note: local renderer unavailable; Generate will render via the HOST browser');   console.error('(pup fallback). Works, slightly lower fidelity; `hero-studio doctor` lists');@@ -108,7 +122,7 @@ const sh = (cmd, a, timeout = 20000) => new Promise((res) => { // checklist. let port = Number(flags.port || process.env.HERO_STUDIO_PORT || 8846); let portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT);-const INSTANCES_DIR = path.join(process.env.HOME || '/home/adom', '.adom', 'instances', 'hero-studio');+const INSTANCES_DIR = path.join(os.homedir(), '.adom', 'instances', 'hero-studio'); const THREAD_SLUG = AI_THREAD.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'unnamed'; if (!portPinned) {   try {@@ -353,8 +367,16 @@ if (!(samePageRepose && !('fresh-showcase' in flags))) { }  // 3. open the studio IN FRONT OF THE HUMAN on the surface they actually use.-const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(port));-if (!proxyBase) { console.error('VSCODE_PROXY_URI not set; cannot build the studio URL'); process.exit(1); }+// In a container the studio is reached through the VS Code proxy; on a+// desktop it is simply http://127.0.0.1:<port>/ and any browser opens it.+const proxyBase = process.env.VSCODE_PROXY_URI+  ? process.env.VSCODE_PROXY_URI.replace('{{port}}', String(port))+  : 'http://127.0.0.1:' + port + '/';+if (!process.env.VSCODE_PROXY_URI && surface !== 'browser') {+  console.error('ERROR [NO_CONTAINER_PROXY]: VSCODE_PROXY_URI is not set, so there is no ' + surface + ' surface to open here.');+  console.error('  On a desktop, re-run with --surface browser (opens ' + proxyBase + ' in the default browser).');+  process.exit(1);+} let tabName = port === 8846 ? 'Hero Studio' : 'Hero Studio :' + port; const SESSION = port === 8846 ? 'hero-studio' : 'hero-studio-' + port; if (surface === 'pup') {@@ -397,6 +419,18 @@ if (surface === 'pup') {     process.exit(3);   }   await sh('adom-desktop', ['browser_alert_window', JSON.stringify({ sessionId: SESSION })]);+} else if (surface === 'browser') {+  // The system default browser (start / open / xdg-open). The URL is printed+  // either way, so a human can paste it if no browser opens.+  const PLAT = (await import('module')).createRequire(import.meta.url)('./platform.cjs');+  const o = PLAT.openUrlCommand(proxyBase);+  try {+    const child = spawn(o.cmd, o.args, { detached: true, stdio: 'ignore', windowsHide: true });+    child.on('error', (e) => console.error('could not launch the default browser (' + o.cmd + ': ' + e.message + '); open the URL by hand'));+    child.unref();+  } catch (e) { console.error('could not launch the default browser (' + e.message + '); open the URL by hand'); }+  tabName = proxyBase;+  console.error('studio URL: ' + proxyBase); } else {   await openWebview(); }
public/js/wizard.js+5
@@ -657,6 +657,11 @@ function brandStepBody(host) {     return d;   }; +  // No wiki identity at all: a private guide then reads as private or+  // missing, and Generate would lock on a guide that is fine. Say why first.+  if (state.identity && state.identity.anonymous) {+    line('This studio is reading the wiki anonymously (no ADOM_WIKI_TOKEN, adom-wiki or adom-cli login, container key, or Adom Bridge session was found), so private brand guides look private or missing. Sign in, then refresh this step.', 'bad');+  }   // Who may edit, in the server's own words.   if (g.editable_by && g.editable_by.text) line(g.editable_by.text + (g.can_edit ? ' You can.' : ' You cannot, so this step is read-only.')); 
render-pup.mjs+3−1
@@ -1,4 +1,5 @@ #!/usr/bin/env node+import { createRequire } from 'module'; // Renders the studio's ?render=1 view via the HOST browser (Adom Desktop pup) // for containers that cannot run Chrome themselves (hero-studio#7). The host // opens the render view through the container proxy URL, screenshots it, and@@ -129,7 +130,8 @@ const py = [   'print("%dx%d" % im2.size)', ].join('\n'); const crop = await new Promise((res) => {-  execFile('python3', ['-c', py, srcPng, out, String(bx), String(by), String(bw), String(bh), String(dpr)],+  const PY = createRequire(import.meta.url)('./platform.cjs').pythonWithPillow() || { cmd: 'python3', args: [] };+  execFile(PY.cmd, [...PY.args, '-c', py, srcPng, out, String(bx), String(by), String(bw), String(bh), String(dpr)],     { timeout: 30000 }, (err, stdout, stderr) => res({ code: err ? (err.code || 1) : 0, out: String(stdout || ''), err: String(stderr || '') })); }); if (srcPng === out + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }
renderer-paths.cjs+33−9
@@ -60,16 +60,39 @@ function puppeteerPath() {   try { return require.resolve('puppeteer-core'); } catch { return null; } } +// Every place a usable Chrome can live, best first: CHROME, the puppeteer+// cache (whose folder name depends on the OS: chrome-linux64, chrome-win64,+// chrome-mac-arm64 / chrome-mac-x64), then the standard system installs.+// Only the Linux cache layout used to be known, so a desktop with Chrome in+// Program Files was told Chrome was missing.+function chromeCandidates(platform = process.platform, env = process.env, home = os.homedir()) {+  const list = [];+  const cache = path.join(home, '.cache', 'puppeteer', 'chrome');+  let versions = [];+  try { versions = fs.readdirSync(cache).sort().reverse(); } catch { versions = []; }+  const inCache = (rel) => versions.map((d) => path.join(cache, d, ...rel));+  if (platform === 'win32') {+    list.push(...inCache(['chrome-win64', 'chrome.exe']), ...inCache(['chrome-win32', 'chrome.exe']));+    for (const base of [env.ProgramFiles, env['ProgramFiles(x86)'], env.LOCALAPPDATA]) {+      if (base) list.push(path.win32.join(base, 'Google', 'Chrome', 'Application', 'chrome.exe'));+    }+  } else if (platform === 'darwin') {+    for (const arch of ['chrome-mac-arm64', 'chrome-mac-x64', 'chrome-mac']) {+      list.push(...inCache([arch, 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']));+    }+    list.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',+      path.join(home, 'Applications', 'Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome'));+  } else {+    list.push(...inCache(['chrome-linux64', 'chrome']));+    list.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser');+  }+  return list;+}+ function chromePath() {   if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME;   if (ISOLATED) return null;-  const base = path.join(os.homedir(), '.cache', 'puppeteer', 'chrome');-  if (fs.existsSync(base)) {-    for (const d of fs.readdirSync(base).sort().reverse()) {-      const p = path.join(base, d, 'chrome-linux64', 'chrome');-      if (fs.existsSync(p)) return p;-    }-  }+  for (const c of chromeCandidates()) if (fs.existsSync(c)) return c;   return null; } @@ -81,11 +104,12 @@ function puppeteerPin(nodeVersion) { }  function puppeteerInstallCommand() {-  return 'npm install --prefix ' + RENDERER_HOME + ' --no-audit --no-fund puppeteer-core@' + puppeteerPin() ++  return 'npm install --prefix "' + RENDERER_HOME + '" --no-audit --no-fund puppeteer-core@' + puppeteerPin() +     '    # outside the package dir, so a package update cannot remove it; no restart needed'; }  function chromeInstallCommand() {+  if (process.platform === 'win32' || process.platform === 'darwin') return require('./platform.cjs').fixFor('chrome');   return 'npx @puppeteer/browsers install chrome@stable --path ' + path.join(os.homedir(), '.cache', 'puppeteer'); } @@ -109,4 +133,4 @@ function quickStatus() {   }; } -module.exports = { DIR, RENDERER_HOME, ISOLATED, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };+module.exports = { DIR, RENDERER_HOME, ISOLATED, chromeCandidates, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };
server.js+47−18
@@ -3,6 +3,9 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process');+// Host facts (home dir, wiki identity, python, container or desktop): one+// module, loaded before anything below asks.+const PLAT = require('./platform.cjs'); // The useful line of a renderer's stderr. Node ends an uncaught exception with a blank line and // "Node.js v18.19.1", so taking the LAST line reported the Node version as the error (2026-09-22: // the human saw "Generate failed: local: Node.js v18.19.1"). Prefer the last line naming an error.@@ -36,7 +39,9 @@ if (!AI_THREAD) { const THREAD_SLUG = AI_THREAD.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'unnamed'; const TTL_HOURS = Number(process.env.APP_TTL_HOURS || argVal('--ttl') || 24); const KEEP_ALIVE = process.argv.includes('--keep-alive');-const INSTANCES_DIR = path.join(process.env.HOME || '/home/adom', '.adom', 'instances', 'hero-studio');+// os.homedir(), not HOME: PowerShell sets no HOME, and the old /home/adom+// fallback put a second, empty state tree at C:\\home\\adom on Windows.+const INSTANCES_DIR = path.join(PLAT.homeDir(), '.adom', 'instances', 'hero-studio'); const ROOT = path.join(__dirname, 'public'); const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.json'), 'utf8')); // The provenance stamp reads the REAL version so it stays truthful across@@ -58,6 +63,7 @@ function rendererStatus() {   return { ...s, running: PKG_VERSION, onDisk }; } function refreshRenderer() {+  { const id = PLAT.wikiIdentity(); state.identity = { source: id.source, anonymous: id.anonymous }; }   const next = rendererStatus();   const strip = (x) => x ? JSON.stringify({ ...x, checkedAt: 0 }) : '';   const changed = strip(state.renderer) !== strip(next);@@ -82,6 +88,7 @@ let state = {   // this replaces the two free-floating accent/bg fields it used to carry.   guide: null,   // { owner, status, sha, version, brand, assets, can_edit, editable_by, url }   renderer: null, // hero-studio#58: { local:{ok,why,fix}, pup:{ok,why}, running, onDisk, checkedAt }+  identity: null, // { source, anonymous }: which wiki identity the studio reads as (never the token)   brandEdits: {},  // staged Brand-step edits, dot path -> value, until a human confirms   verify: false,  // the pose gate turns this on: verification chrome + checklist   confirm: { title: false, subtitle: false, showcase: false, type: false },@@ -283,9 +290,13 @@ try {     poseRev += 1;     merge(state, snap.state);     if (snap.changes) changes = snap.changes;-    logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ')');+    logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ') from ' + SNAP);+  } else {+    logLine('info', 'session snapshot at ' + SNAP + ' is older than 24 h; starting fresh');   }-} catch {}+} catch (e) {+  logLine('info', 'no session snapshot restored: looked for ' + SNAP + (e && e.code === 'ENOENT' ? ' (none there)' : ' (' + (e && e.message) + ')'));+} let snapTimer = null; function saveSnapshot() {   clearTimeout(snapTimer);@@ -389,7 +400,7 @@ async function fetchRemoteGates() {   try {     const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';     const headers = {};-    try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+    Object.assign(headers, PLAT.authHeader());     const who = await fetch(REG + '/auth/me', { headers }).then((r) => (r.ok ? r.json() : null)).catch(() => null);     const handle = who && (who.username || who.name || who.handle || (who.user && (who.user.username || who.user.name || who.user.handle)));     if (!handle) return;@@ -421,7 +432,7 @@ function isSeasoned() { async function refreshPageLive(ref) {   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = {};-  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());   const r = await fetch(REG + '/api/pages/' + ref, { headers, redirect: 'follow' });   if (!r.ok) throw new Error('page fetch ' + r.status);   const pj = await r.json();@@ -461,7 +472,7 @@ async function confirmBrandGuide(human, fromGate) {   const owner = g.owner;   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = { 'Content-Type': 'application/json' };-  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());   let handle = String(human || '').trim();   if (!handle) {     try {@@ -507,7 +518,7 @@ async function resolveBrandGuide(owner, { fresh = false } = {}) {   if (!fresh && hit && Date.now() - hit.at < BRAND_TTL_MS) return hit.guide;   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = {};-  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());   let guide;   try {     const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand', { headers });@@ -569,7 +580,7 @@ const starterOutcome = new Map(); async function draftStarter(owner) {   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = { 'Content-Type': 'application/json' };-  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());   let outcome;   try {     const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand/starter', { method: 'POST', headers, body: '{}' });@@ -591,7 +602,7 @@ async function draftStarter(owner) {         // the step waiting for a draft that will never come.         outcome.result = 'exists_unreadable';         outcome.error = owner + '/brand exists on the registry but this studio cannot read it';-        outcome.hint = 'the studio reads the guide as the human at the gate (the container key at /var/run/adom/api-key); check that key is present and that this identity can read ' + owner + '/brand';+        { const id = PLAT.wikiIdentity(); outcome.hint = 'the studio reads the guide as ' + (id.anonymous ? 'NOBODY (no wiki identity found: set ADOM_WIKI_TOKEN, sign in with adom-wiki or Adom Bridge)' : 'the identity from ' + id.source) + '; check that this identity can read ' + owner + '/brand'; }         logLine('warn', 'brand starter for ' + owner + ': exists, but the resolver answers missing (identity mismatch?)');       } else {         outcome.result = 'timeout';@@ -656,7 +667,7 @@ async function refreshGuide({ fresh = false } = {}) { async function showcaseCandidates(ref) {   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = {};-  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());   const slot = state.slotSize && state.slotSize.w     ? state.slotSize.w + 'x' + state.slotSize.h     : 'the size the left panel shows';@@ -772,7 +783,7 @@ async function showcaseCandidates(ref) { async function heroProvenance(ref) {   const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';   const headers = {};-  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  Object.assign(headers, PLAT.authHeader());   let heroPath = 'screenshots/hero.png';   try {     const pj = await (await fetch(REG + '/api/pages/' + ref + '/files/page.json', { headers })).json();@@ -984,7 +995,7 @@ function route(req, res, url, p) {       if (!fields.length) return finish({ ok: false, error: 'no page-field edits to apply' });       const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';       const headers = { 'Content-Type': 'application/json' };-      try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+      Object.assign(headers, PLAT.authHeader());       const body = { changelog: 'Edited in Hero Studio: ' + fields.join(', ') };       for (const f of fields) body[f] = changes.page[f].to;       const stash = () => {@@ -1122,7 +1133,7 @@ function route(req, res, url, p) {     const ref = url.searchParams.get('page') || state.page;     const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';     const headers = {};-    try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+    Object.assign(headers, PLAT.authHeader());     return (async () => {       const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };       try {@@ -1210,7 +1221,7 @@ function route(req, res, url, p) {     }     if (!target) { res.writeHead(404); return res.end('no such brand asset'); }     const headers = {};-    try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+    Object.assign(headers, PLAT.authHeader());     fetch(target, { headers })       .then(async (r) => {         if (!r.ok) { res.writeHead(r.status); return res.end('brand asset ' + r.status); }@@ -1293,7 +1304,7 @@ function route(req, res, url, p) {     const wantPath = url.searchParams.get('path') || '';     const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';     const headers = {};-    try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+    Object.assign(headers, PLAT.authHeader());     const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };     (async () => {       const lr = await fetch(REG + '/api/pages/' + ref + '/files', { headers });@@ -1657,11 +1668,29 @@ function route(req, res, url, p) {         '    im = im.resize((2000, round(2000 * im.size[1] / im.size[0])), Image.LANCZOS)\n' +         'info = PngInfo(); info.add_text("comment", meta)\n' +         'im.save(f, pnginfo=info)\n';-      execFile('python3', ['-c', py, outFile, meta], (err) => {-        if (err) return execFile('convert', [outFile, '-resize', '2000x', '-set', 'comment', meta, outFile], (err2) => {-          if (err2) logLine('warn', 'no python3/PIL or convert; keeping full-size PNG without metadata');+      // The stamp is what the registry checks, so an unstamped PNG is a+      // failed Generate, not a success with a console warning (the human+      // would only learn at publish). Python with Pillow first (python3,+      // python, or py -3: on Windows python3 is usually the Store alias+      // stub), then ImageMagick (`magick`; never `convert` on Windows, which+      // is the volume converter there).+      const stampFailed = (why) => {+        logLine('error', 'provenance stamp failed: ' + why);+        finish({ ok: false, errorCode: 'STAMP_FAILED', error: 'the hero rendered but could not be stamped with its provenance, so it was not kept as a result: ' + why,+          hint: 'install Pillow for a Python on PATH (' + PLAT.fixFor('pillow') + ') or ImageMagick 7 (magick), then Generate again; hero-studio doctor checks this' });+      };+      const pyCmd = PLAT.pythonWithPillow();+      const viaMagick = () => {+        const magick = PLAT.imageMagick();+        if (!magick) return stampFailed('no Python with Pillow' + (pyCmd ? ' that ran cleanly' : '') + ' and no ImageMagick on PATH');+        execFile(magick, [outFile, '-resize', '2000x', '-set', 'comment', meta, outFile], { windowsHide: true }, (err2) => {+          if (err2) return stampFailed(magick + ' failed: ' + String(err2.message || err2).split('\n')[0]);           afterFinalize();         });+      };+      if (!pyCmd) viaMagick();+      else execFile(pyCmd.cmd, [...pyCmd.args, '-c', py, outFile, meta], { windowsHide: true }, (err) => {+        if (err) { logLine('warn', pyCmd.label + ' stamp failed (' + String(err.message || err).split('\n')[0] + '); trying ImageMagick'); return viaMagick(); }         afterFinalize();       });       function afterFinalize() {