app
Hero Studio
Public Made by Adomby adom
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 P
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
#!/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);