master
Colby Knox Publish 0.22.1 950bd14 19d ago
#!/usr/bin/env node
// hero-studio pose <owner/slug> [--title T] [--brief B] [--type app|skill|component] [--timeout SECS]
//
// The human gate. Launches Hero Studio in front of the human, posed for the
// given package, then BLOCKS until a human clicks Generate in the UI. Prints
// the generated PNG path on stdout and exits 0. An AI that needs a hero runs
// this and waits; it cannot continue without a human-made image.
//
// Exit codes: 0 generated, 2 timeout (no human generated), 1 setup error,
// 3 pup failed with --no-fallback set (default: diagnosed webview fallback so
// the gate still reaches the human), 4 POSE_BUSY (same page mid-gate; use
// --steal / --port).
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';

const DIR = path.dirname(fileURLToPath(import.meta.url));
let BASE = 'http://localhost:8846/';

const args = process.argv.slice(2);
const flags = {};
const pos = [];
// Valueless flags MUST be declared: the old parser consumed the next token as
// a value, so `--steal --headline "X"` silently dropped the headline (#42).
const BOOL_FLAGS = new Set(['steal', 'fresh-showcase', 'no-fallback', 'keep-alive', 'seed-only', 'no-local']);
for (let i = 0; i < args.length; i++) {
  if (!args[i].startsWith('--')) { pos.push(args[i]); continue; }
  const name = args[i].slice(2);
  if (BOOL_FLAGS.has(name) || args[i + 1] === undefined || args[i + 1].startsWith('--')) {
    flags[name] = true;
  } else {
    flags[name] = args[i + 1];
    i++;
  }
}

// The ai-thread name (#39): required, forwarded to the server at spawn and on
// every POST, and handed onward to the desktop bridge (#42.2).
const AI_THREAD = flags['ai-thread'] || process.env.ADOM_AI_THREAD || '';
if (!AI_THREAD) {
  console.error('caller_identity_required: pose needs the ai-thread name.');
  console.error('  Re-run: hero-studio --ai-thread "<name>" pose <owner/slug> --surface ...');
  console.error('  Pick a short name a HUMAN would recognize for THIS conversation and reuse it on every call.');
  process.exit(1);
}
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|browser [--title T] [--brief B] [--type app|skill] [--timeout SECS]');
  process.exit(1);
}
const surface = flags.surface;
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.');
  process.exit(1);
}
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 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');
  console.error('what the full local runtime needs.');
}

const jf = async (p, opts) => {
  if (opts && opts.method === 'POST') {
    const body = opts.body ? JSON.parse(opts.body) : {};
    if (!body.aiThread) body.aiThread = AI_THREAD;
    opts = Object.assign({}, opts, { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } });
  }
  const r = await fetch(BASE + p, opts);
  if (!r.ok) throw new Error(p + ' -> ' + r.status);
  return r.json();
};
// Multiple desktops on one relay need addressing (#24 ask 3): --target
// <name> or ADOM_TARGET. All desktop-bridge calls go through the adapter
// (hero-studio#31: the Adom Bridge v2 rename changed the binary AND the verb
// prefix; the adapter resolves both at runtime).
const AD_TARGET = flags.target || process.env.ADOM_TARGET || '';
const sh = (cmd, a, timeout = 20000) => new Promise((res) => {
  execFile(cmd, a, { timeout }, (err, stdout) => res(err ? null : stdout));
});

// 1. pick an instance (hero-studio#15: concurrent studios). Explicit
// --port/HERO_STUDIO_PORT wins. Otherwise use the default port, EXCEPT when
// that server is mid-gate for a DIFFERENT page: then spawn a fresh instance on
// a free port so this pose can never clobber another thread's half-reviewed
// 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(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 {
    const mine = JSON.parse(fs.readFileSync(path.join(INSTANCES_DIR, THREAD_SLUG + '.json'), 'utf8'));
    process.kill(mine.pid, 0);
    port = mine.port;
    portPinned = true;
    console.error('note: reusing this thread\'s live studio on :' + port + ' (started ' + mine.startedAt + '); hero-studio ls shows all instances');
  } catch {}
  try {
    for (const f of fs.readdirSync(INSTANCES_DIR)) {
      if (!f.endsWith('.json') || f === THREAD_SLUG + '.json') continue;
      const e = JSON.parse(fs.readFileSync(path.join(INSTANCES_DIR, f), 'utf8'));
      try { process.kill(e.pid, 0); } catch { continue; }
      if (e.task === ref) console.error('warning: thread "' + e.thread + '" already has a studio posing ' + ref + ' on :' + e.port + '; duplicate-task is usually a mistake');
    }
  } catch {}
}
BASE = 'http://localhost:' + port + '/';
let state = null;
try { state = await jf('state'); } catch {}
if (state && state.page === ref && state.verify
    && !(state.approved && state.approved.page === ref) && !('steal' in flags)) {
  // Same page, gate already open: a second pose would silently clobber the
  // first thread's staging (#24 ask 6). Refuse and NAME the holder.
  const since = state.posedAt ? new Date(state.posedAt).toISOString() : 'unknown time';
  console.error('ERROR [POSE_BUSY]: a gate for ' + ref + ' is already open on :' + port + ' (posed at ' + since + ').');
  console.error('  Options: wait for that review; re-run with --steal to take over the open gate');
  console.error('  (same staging, fresh checklist); or --port <n> for a parallel instance.');
  process.exit(4);
}
if (state && !portPinned) {
  const gateBusy = state.verify && state.page && state.page !== ref
    && !(state.approved && state.approved.page === state.page);
  if (gateBusy) {
    const busyFor = state.page;
    let free = 0;
    for (let cand = port + 1; cand <= port + 40 && !free; cand++) {
      BASE = 'http://localhost:' + cand + '/';
      try { await jf('state'); } catch { free = cand; }
    }
    if (!free) { console.error('no free port found near ' + port); process.exit(1); }
    console.error('note: :' + port + ' is mid-gate for ' + busyFor + '; using a fresh instance on :' + free + ' so neither pose clobbers the other (hero-studio#15)');
    port = free;
    state = null;
  } else {
    BASE = 'http://localhost:' + port + '/';
  }
}
if (!state) {
  spawn(process.execPath, [path.join(DIR, 'server.js'), '--port', String(port), '--ai-thread', AI_THREAD], { detached: true, stdio: 'ignore', env: process.env }).unref();
  for (let i = 0; i < 20 && !state; i++) {
    await new Promise((r) => setTimeout(r, 500));
    try { state = await jf('state'); } catch {}
  }
  if (!state) { console.error('hero-studio server did not come up on :' + port); process.exit(1); }
}

// 2-local. The LOCAL PACKAGE subject source (hero-studio#52): a first hero is
// a PUBLISH PREREQUISITE, so at hero time the page often does not exist on the
// wiki yet -- but everything the studio needs is sitting in the invoking
// directory. When ./package.json (or page.json) matches the posed slug, its
// fields seed the subject and its images become showcase candidates.
// Precedence: local package -> wiki page -> generic mock ("prefer what is
// about to be published over what is already published"); explicit --title/
// --brief flags outrank everything. --no-local opts out.
let localPkg = null;
let localImages = [];
if (!('no-local' in flags)) {
  for (const mf of ['package.json', 'page.json']) {
    try {
      const m = JSON.parse(fs.readFileSync(path.join(process.cwd(), mf), 'utf8'));
      if ((m.slug || m.name || '').replace(/^.*\//, '') === slug) { localPkg = m; break; }
    } catch {}
  }
  if (localPkg) {
    const seen = new Set();
    const addImgs = (dir, excl) => {
      try {
        for (const f of fs.readdirSync(path.join(process.cwd(), dir))) {
          if (!/\.(png|jpe?g|webp)$/i.test(f)) continue;
          if (excl && excl.test(f)) continue;
          const rel = path.join(dir, f);
          if (!seen.has(rel)) { seen.add(rel); localImages.push(path.join(process.cwd(), rel)); }
        }
      } catch {}
    };
    // UI/part screenshots first (the old hero.png is the thing being REPLACED,
    // so it goes last, never first)
    addImgs('screenshots', /^hero\./i);
    addImgs('docs');
    try { if (fs.existsSync(path.join(process.cwd(), slug + '.png'))) localImages.push(path.join(process.cwd(), slug + '.png')); } catch {}
    console.error('local package subject: ' + (localPkg.title || slug) + ' (' + (localImages.length || 'no') + ' local image candidate(s))');
  }
}

// 2. make sure the studio knows this package, then pose it.
// --type accepts a comma list for multi-type pages (hero-studio#19,
// e.g. --type app,service): the first entry drives the mode, the full set
// renders as a pill group.
const localType = localPkg && (localPkg.type === 'hardware' ? 'component' : localPkg.type);
const title = flags.title || (localPkg && localPkg.title) || slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
const localBrief = localPkg && (localPkg.brief || localPkg.description);
const typeList = String(flags.type || localType || 'app').split(',').map((t) => t.trim()).filter(Boolean);
await jf('api/pages', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(Object.fromEntries(Object.entries(
    { owner, slug, title, type: typeList[0], types: typeList.length > 1 ? typeList : null, brief: flags.brief || localBrief },
  ).filter(([, v]) => v))),
});
// Every type poses into the app composition (title + subtitle + showcase
// window): that is the surface the wizard drives and the only one that honors
// an imported showcase. The studio's legacy 'component' mode is a sample-parts
// design lab (built-in photos of a JST connector, its own text zone) that
// ignores the posed page AND the showcase, so posing --type component into it
// produced heroes of a completely different part (Arav, basic-parts rollout).
// It stays reachable from Classic for what it is. Hardware pages get a bare,
// frameless showcase by default (app.js chrome auto).
const poseMode = 'app';
const samePageRepose = !!(state && state.page === ref && state.showcase && state.showcase.src);
if (samePageRepose && !('fresh-showcase' in flags)) {
  console.error('note: re-posing ' + ref + ' with staged showcase content preserved (' + Math.round(state.showcase.src.length / 1024) + 'kB' + (state.showcase.full ? ', full-hero import' : '') + '); pass --fresh-showcase to start clean');
}
await jf('state', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    mode: poseMode, page: ref,
    // #53: the legacy component lab's built-in sample part (a real JST MPN)
    // must never ride into a posed session; a posed page's comp slot is inert
    comp: { id: null },
    // the gate presentation: verification chrome + a fresh checklist
    verify: true, tour: true, marqueePreview: true, guides: false,
    confirm: { title: false, subtitle: false, showcase: false, type: false },
    flags: { title: false, subtitle: false, showcase: false, type: false },
    lint: [],
    // #33: the baked headline is pose state, distinct from the page title;
    // --headline sets it, otherwise reset so the previous page's cannot leak
    headline: flags.headline || '',
    // ...and the same for the subtitle, which was NOT reset: posing a new page
    // after editing one carried the previous page's subtitle across, so a
    // fresh page rendered someone else's sentence under its title. A
    // same-page re-pose keeps it, like the rest of the staged work.
    ...(samePageRepose ? {} : { subtitle: '' }),
    posedAt: Date.now(),
    // Clear the previous package's showcase so a failed auto-pull can never
    // leave sibling A's screenshot in sibling B's hero -- but PRESERVE staged
    // work when re-posing the SAME page (#24 ask 4: a crashed or timed-out
    // gate must not destroy a staged full-hero import). --fresh-showcase
    // forces the clear. Artwork state (theme, layout, angle, brand) always
    // carries: that IS the platform-twin flow.
    ...(samePageRepose && !('fresh-showcase' in flags) ? {} : { showcase: { src: '', full: false } }),
  }),
});

// 2a-live. re-pull title/brief/type from the wiki (#30/#33). For a page with
// NO local package, the live page wins as before. With a local package the
// PRECEDENCE INVERTS (#52): local fields are what is about to be published,
// so they are re-asserted over the live row after the pull (which still runs
// for the metadata the local manifest lacks: score, stars, author).
try {
  const lv = await jf('refresh-page?page=' + ref, { method: 'POST' });
  if (lv && lv.ok) console.error('live page: "' + lv.title + '"' + (localPkg ? ' (local package fields win for this pose)' : ''));
} catch {}
if (localPkg) {
  await jf('api/pages', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(Object.fromEntries(Object.entries(
      { owner, slug, title, type: typeList[0], brief: flags.brief || localBrief },
    ).filter(([, v]) => v))),
  });
}
// 2a-brand. Resolve the PAGE OWNER's brand guide and say where it stands
// before the human reaches the gate. A hero wears the brand of the owner whose
// page it is, so a starter, missing, invalid or unreachable guide is something
// the human decides about at the Brand step, not something this command
// silently papers over with Adom's look.
try {
  const br = await jf('brand?fresh=1');
  const g = (br && br.guide) || {};
  const label = {
    confirmed: 'confirmed',
    starter: 'STARTER (not yet confirmed by a human)',
    missing: 'MISSING',
    invalid: 'INVALID',
    private: 'PRIVATE (not readable with this identity)',
    unavailable: 'UNAVAILABLE (the registry did not answer)',
  }[g.status] || String(g.status || 'unknown');
  console.error('brand guide for ' + owner + ': ' + label + (g.version ? ' (v' + g.version + (g.sha ? ' ' + String(g.sha).slice(0, 7) : '') + ')' : ''));
  if (g.url) console.error('  guide: ' + g.url);
  for (const e of (g.errors || []).slice(0, 6)) {
    console.error('  error: ' + (e && e.field ? e.field + ': ' : '') + (e && e.message || e));
  }
  for (const w of (g.warnings || []).slice(0, 6)) console.error('  warning: ' + w);
  for (const h of (g.hints || []).slice(0, 6)) console.error('  hint: ' + hintLine(h));
  if (g.status !== 'confirmed') {
    // editable_by.text is the server's sentence about who may edit this
    // guide. Relay it verbatim: this thread does not get to paraphrase the
    // rule, and the human at the gate is the one it applies to.
    if (g.editable_by && g.editable_by.text) console.error('  who may edit: ' + g.editable_by.text);
    else if (!g.can_edit) console.error('  this identity cannot edit ' + owner + '/brand');
    console.error('  the wizard opens on a Brand step; the guide is confirmed by a human there, never by a thread');
  }
} catch (e) {
  console.error('brand guide for ' + owner + ': could not be resolved (' + (e && e.message || e) + '); the hero will hold rather than fall back to Adom\'s look');
}

// 2a-showcase. Stage the best local image as the showcase (unless staged work
// is being preserved on a same-page re-pose, or the caller staged their own).
if (localImages.length && !(samePageRepose && !('fresh-showcase' in flags))) {
  const pick = localImages[0];
  const sc = await jf('showcase-file', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ path: pick }),
  });
  if (sc && sc.ok) {
    console.error('local showcase staged: ' + path.relative(process.cwd(), pick) + (localImages.length > 1 ? ' (alternatives: ' + localImages.slice(1, 4).map((f) => path.relative(process.cwd(), f)).join(', ') + '; swap with `hero-studio showcase --file <path>`)' : ''));
  }
}
// Test hook (#52 verification): everything above ran; skip the human gate.
if ('seed-only' in flags) {
  const st = await jf('state');
  const sg = st.guide || {};
  console.log(JSON.stringify({ ok: true, seedOnly: true, page: st.page, resolved: st.resolved, showcaseBytes: (st.showcase && st.showcase.src || '').length, localImages: localImages.map((f) => path.relative(process.cwd(), f)), brand: { status: sg.status || 'missing', owner: sg.owner || null, sha: sg.sha || null, version: sg.version || null, can_edit: !!sg.can_edit } }));
  process.exit(0);
}

// 2b. fresh change ledger for THIS review session (#20): everything the human
// edits at the gate (page fields + pose deltas) accumulates from here and
// rides back in this command's final JSON as `changes`.
try {
  await jf('changes/reset', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ref }),
  });
} catch {}

// 2c. best-effort: pre-load a real screenshot from the package's wiki page
// into the showcase, so the mock only shows when the page has no images.
// Skipped when a same-page re-pose preserved staged content (#24 ask 4).
if (!(samePageRepose && !('fresh-showcase' in flags))) {
  try { await jf('showcase-from-page?page=' + ref, { method: 'POST' }); } catch {}
}

// 3. open the studio IN FRONT OF THE HUMAN on the surface they actually use.
// 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') {
  // Adom Desktop pup window. Reuse an existing session for this instance;
  // never spawn a duplicate window (browser_status first, then
  // navigate-or-open). Every step's real output is kept for diagnosis.
  const bridge = await makeBridge(AD_TARGET, AI_THREAD);
  console.error('desktop bridge: ' + bridge.generation + ' (verbs: ' + bridge.verbs.open_window + ' family)');
  const steps = [];
  const status = await bridge.call('status', {}, 15000);
  steps.push([bridge.verbs.status, status]);
  const hasSession = status.ok && status.out.includes(SESSION);
  const looksOk = (r) => r.ok && !r.out.includes('"error"') && !r.out.includes('_adom_use_cli_instead');
  let opened = hasSession
    ? await bridge.call('navigate', { sessionId: SESSION, url: proxyBase }, 30000)
    : await bridge.call('open_window', { sessionId: SESSION, profile: SESSION, url: proxyBase }, 60000);
  steps.push([hasSession ? bridge.verbs.navigate : bridge.verbs.open_window, opened]);
  if (!looksOk(opened) && !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 it dead.
    for (let i = 0; i < 6 && !looksOk(opened); i++) {
      await new Promise((r) => setTimeout(r, 5000));
      const st = await bridge.call('status', {}, 15000);
      if (st.ok && st.out.includes(SESSION)) opened = { ok: true, out: st.out, errOut: '', fail: '' };
    }
  }
  if (!looksOk(opened)) {
    // hero-studio#12: NO silent webview fallback. The user asked for pup;
    // landing them on a surface they didn't ask for (and fighting the
    // never-both-surfaces rule) is worse than failing with the reason.
    console.error('ERROR [PUP_OPEN_FAILED]: could not open the studio on the pup surface. What each step actually said:');
    for (const [name, r] of steps) {
      const detail = (r.fail ? r.fail + '; ' : '') + (r.out || r.errOut || '(no output)').trim().slice(0, 300);
      console.error('  ' + name + ': ' + detail.replace(/\n/g, ' '));
    }
    console.error('  likely causes: the Adom Desktop app is not running / the relay is down / the verb was');
    console.error('  refused (approval or caller-identity gate; see the adom-desktop-permissions skill) /');
    console.error('  a cold profile outliving every timeout.');
    console.error('  Fix the cause and re-run, or re-run with --surface webview if the human agrees.');
    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();
}
async function openWebview() {
  // Hydrogen webview tab, proxy mode OFF (proxy mode silently blocks all page
  // JS, which leaves the studio dead), never the VS Code pane.
  const tabsRaw = await sh('adom-cli', ['hydrogen', 'workspace', 'tabs']);
  let panelId = null;
  try {
    const tabs = JSON.parse(tabsRaw).tabs || [];
    const existing = tabs.find((t) => (t.name || '').includes('Hero Studio'));
    const anyWebview = tabs.find((t) => t.name !== 'Visual Studio Code');
    if (existing) { tabName = existing.name; panelId = existing.panelId; }
    else if (anyWebview) panelId = anyWebview.panelId;
  } catch {}
  if (!panelId) { console.error('no webview pane found to open the studio in'); process.exit(1); }
  await sh('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', tabName, '--url', proxyBase, '--panel-id', panelId]);
  await sh('adom-cli', ['hydrogen', 'webview', 'set-proxy', 'false', '--name', tabName]);
}

// 3b. THE SHOWCASE MANDATE. Wait for the UI to measure the slot, then tell the
// AI exactly what a real capture requires. A generic mock showcase is a
// reprimand, not a shrug.
let slot = null;
for (let i = 0; i < 8; i++) {
  await new Promise((r) => setTimeout(r, 750));
  try {
    const st = await jf('state');
    if (st.slotSize && st.slotSize.w) { slot = st.slotSize; state = st; break; }
    state = st;
  } catch {}
}
const slotTxt = slot ? slot.w + 'x' + slot.h + ' (aspect ' + (slot.w / slot.h).toFixed(2) + ')' : 'the size shown in the left panel';
if (!(state.showcase && state.showcase.src)) {
  // What this page could actually show, ranked, so the decision is made for
  // the human instead of asked of them (hero-studio#57).
  let cand = null;
  try { cand = await jf('showcase-candidates?page=' + ref); } catch {}
  console.error('');
  console.error('REPRIMAND [SHOWCASE_IS_GENERIC]: the showcase is showing the GENERIC MOCK.');
  if (cand && cand.ok && cand.candidates && cand.candidates.length) {
    console.error('  CANDIDATES for ' + ref + ' (' + cand.type + '), best first:');
    for (const c of cand.candidates.slice(0, 5)) {
      console.error('    [' + c.kind + '] ' + c.what);
      console.error('        why: ' + c.why);
      console.error('        ' + c.cmd);
    }
    console.error('  ' + cand.hint);
    console.error('  Pick one and load it YOURSELF; do not ask the human to find a screenshot.');
    console.error('');
  }
  console.error('  You MUST capture a real screenshot of ' + ref + "'s actual UI and load it:");
  console.error('  1. open the app in a pup window and size the capture to ' + slotTxt + ' so nothing crops:');
  console.error('     adom-desktop browser_open_window \'{"sessionId":"showcase-shot","profile":"showcase-shot","url":"<the app\'s URL>"}\'');
  console.error('     adom-desktop browser_eval \'{"sessionId":"showcase-shot","expr":"window.resizeTo(' + (slot ? slot.w / 2 : 1000) + ',' + (slot ? slot.h / 2 : 640) + ')"}\'');
  console.error('     adom-desktop browser_screenshot \'{"sessionId":"showcase-shot"}\'');
  console.error('  2. load it into the studio: hero-studio showcase --file <that screenshot.png>');
  console.error('  Do NOT let the human generate over the generic mock unless the page truly has no UI.');
  console.error('');
} else {
  console.error('showcase: loaded (' + (state.showcase.src.length / 1024 | 0) + ' KB). For a sharper capture, shoot at ' + slotTxt + ' and run: hero-studio showcase --file <shot.png>');
}

// 4. block until a HUMAN clicks Generate (a new _gen seq)
const baseline = (state._gen && state._gen.seq) || 0;
console.error('studio open (' + surface + ', :' + port + ', tab "' + tabName + '"), posed for ' + ref + '. Waiting for a human to click Generate (timeout ' + timeoutSecs + 's)...');
const deadline = Date.now() + timeoutSecs * 1000;
while (Date.now() < deadline) {
  await new Promise((r) => setTimeout(r, 2000));
  let st = null;
  try { st = await jf('state'); } catch { continue; }
  const g = st._gen;
  if (g && g.ok && g.seq > baseline) {
    const abs = path.join(DIR, 'generated', g.file);
    let sessionChanges = null;
    try { sessionChanges = await jf('changes'); } catch {}
    console.log(JSON.stringify({ ok: true, ref, port, file: g.file, path: fs.existsSync(abs) ? abs : path.join(DIR, g.url), latest: path.join(DIR, 'generated', 'latest.png'), changes: sessionChanges }));
    if (sessionChanges && (Object.keys(sessionChanges.page || {}).length || Object.keys(sessionChanges.pose || {}).length)) {
      console.error('NOTE: the human made changes at the gate (see .changes in the JSON above).');
      console.error('Sync the local package (page.json / package.json / README) to match, or the');
      console.error('next publish silently reverts what the human decided.' + (sessionChanges.applied === 'wiki' ? ' (Already applied to the wiki; the LOCAL checkout still needs it.)' : ''));
    }
    process.exit(0);
  }
}
console.error('timeout: no human generated a hero within ' + timeoutSecs + 's');
process.exit(2);