← Commit history
SKILL.md+43−1
@@ -9,6 +9,27 @@ A local web app (plain Node, no dependencies) that renders a full 16:10 hero liv pixel-faithful wiki contexts, and generates the provenance-stamped 2000x1250 PNG the registry's hero gate requires. +## The ai-thread name is REQUIRED (hero-studio#39)++Every server start and every state-changing verb needs the global flag+`--ai-thread "<name>"` FIRST (the settled platform term; see the+app-creator-ai-thread-name skill). Pick a short name a human would recognize+for THIS conversation ("kicad board review", never "agent" or a UUID) and+reuse it on every call to every Adom tool. Anonymous state-changing calls get+a caller_identity_required teaching refusal with the exact retry. Read-only+verbs (state, changes, ls, compose, console) stay open. The name is+self-reported attribution, never authorization.++Lifecycle (hero-studio#38/#39, per the app-creator-server-lifecycle skill):+instances self-reap after 24h idle (`--ttl <hours>`, `--keep-alive` for+daemons) with a lossless final snapshot; snapshots are PER THREAD+(~/.adom/instances/hero-studio/snapshots/), so restarting with the same+--ai-thread restores your pose and parallel threads never see each other's+state. `hero-studio ls` lists live instances (port, thread, task, idle,+version drift after package updates); starting with a thread that already has+a live instance reuses it instead of respawning; `--port auto` binds a free+port. Zombie cleanup is automatic; `stop --port N` remains for manual ends.+ ## The one rule: a human makes the hero  An AI never generates a hero on the human's behalf. The canonical flow is the pose@@ -163,7 +184,10 @@ composed checks cannot see imported pixels, and an edge-density scan of the lower band flags text-like structure there (heuristic, hero-studio#22). Quoted prompt-looking text in the image zone warns too: sample prompts belong in page metadata (sample_prompts), which the dynamic overlay previews with a-copy button; a baked prompt is pixels nobody can copy. Contrast checking is+copy button; a baked prompt is pixels nobody can copy. Cover-cropped vitrines+get accounting (#41): the linter reports what percent of the source screenshot+the crop discards and heuristically flags content cut mid-element at the crop+lines (beheaded logos and headings read as broken). Contrast checking is not in v1.  ## Verification mode (what the pose gate shows)@@ -220,6 +244,8 @@ hero-studio set fontSize.title=9.5             # dot-paths build nested patches hero-studio set fontSize.leading=0.96 fontSize.gap=1.2   # title line-height + text-block gap hero-studio set headline="Pose. Approve. Ship."  # what BAKES as the title; page title stays metadata hero-studio set --list                         # the valid enum tokens (also in GET /state as `enums`)+hero-studio fit                                # solve the LARGEST title size with <=3 lines + positive headroom (#40); auto-runs on headline edits, and presets re-fit with their size as a ceiling. If no size makes 3 lines, it fits the hard constraints and tells you to shorten the copy: shorter copy reads bigger+hero-studio vitrine --from-url <url> [--selector <css>]  # the studio drives the desktop bridge itself: slot math, DPR, sentinel check, aspect crop (#41); falls back with a clear message when no bridge answers hero-studio preset bridge-family               # apply a family design system in one call; no name lists them hero-studio vitrine --file shot.png [--full] [--cover] [--chrome none|fake|bare] hero-studio bg --file tile.png [--cover]       # YOUR brand texture as theme 'image'; composed text + linting still apply@@ -274,6 +300,22 @@ every overlay off, Generate BYPASSES the browser entirely (provenance `renderer: passthrough`): your pixels are decoded, normalized to 2000x1250, and stamped, with zero recompression drift (hero-studio#36). +## The hero doctrine: ONE message (hero-studio#43)++1. A hero image gets across exactly ONE message. Six messages = zero messages.+2. That one message IS the title, ideally approaching one word. The vitrine is+   supporting evidence, not the message.+3. White space is not wasted space; negative space is what makes the one+   message land.+4. The subtitle is deliberately small BECAUSE nobody is assumed to read it. If+   the design only works when the subtitle is read, the hero has failed. Same+   reason the lower marquee band stays calm.++The gate walkthrough teaches this per step, and the linter enforces the+measurable parts: vitrine coverage above ~72% of the hero warns, and a+full-import whose zones read as several competing text blocks warns (a 4x3+edge-density grid; heuristic and says so).+ ## Brand doctrine (what makes a hero look like the family)  Written here because it used to live only in the studio's internal CSS and
bin/hero-studio+15−2
@@ -4,17 +4,26 @@ # studio in front of the human and blocks until they click Generate. SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)"+if [ "$1" = "--ai-thread" ]; then+  export ADOM_AI_THREAD="$2"+  shift 2+fi case "$1" in   pose) shift; exec node "$DIR/pose.mjs" "$@" ;;   doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;-  vitrine|set|preset|state|toast|reroll|changes|bg|compose|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;+  vitrine|set|preset|fit|state|toast|reroll|changes|bg|compose|ls|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;+  start) shift; exec node "$DIR/server.js" "$@" ;;   -h|--help|help)     # hero-studio#36: --help used to fall through to the server catch-all and     # silently START an instance; help must never have side effects.     cat <<'HELP' hero-studio: pose package heroes in front of a human, generate approved PNGs -  hero-studio                          start the studio server (foreground; --port <n>)+Every verb takes the global flag --ai-thread "<name>" FIRST (or env+ADOM_AI_THREAD on single-agent boxes): the settled platform term for which+conversation is calling. State-changing verbs refuse without it.++  hero-studio --ai-thread "<name>" [start]   start the server (--port <n>|auto, --ttl <hours>, --keep-alive)   hero-studio pose <owner/slug> --surface webview|pup                                        open the pose gate and block for approval         [--title T] [--brief B] [--type app|skill|component[,more]]@@ -22,6 +31,10 @@ hero-studio: pose package heroes in front of a human, generate approved PNGs         [--port <n>] [--timeout SECS]   hero-studio doctor                   verify both render paths, print exact fixes   hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake|bare]+  hero-studio vitrine --from-url <url> [--selector <css>]+                                       capture the vitrine ITSELF via the desktop bridge at slot aspect+  hero-studio fit                      solve the largest title size that fits (<=3 lines, headroom)+  hero-studio ls                       instance registry: live studios, idle time, version drift   hero-studio bg --file <image> [--cover]   hero-studio set '<json>' | key=value [...] | set --list   hero-studio preset [name]            apply a family design system; no name lists them
bridge.mjs+12−7
@@ -8,9 +8,13 @@ // answer, so never hardcode), and falls back to legacy adom-desktop. import { execFile } from 'child_process'; -const run = (bin, args, timeout, target) => new Promise((res) => {-  const a = target ? [...args, '--target', target] : args;-  execFile(bin, a, { timeout }, (err, stdout, stderr) => res({+const run = (bin, args, timeout, target, thread) => new Promise((res) => {+  let a = target ? [...args, '--target', target] : [...args];+  // Caller identity rides every call (#42.2): adom-bridge-cli takes the+  // global --ai-thread flag; legacy adom-desktop reads ADOM_AI_THREAD env.+  if (thread && bin === 'adom-bridge-cli') a = ['--ai-thread', thread, ...a];+  const env = thread ? Object.assign({}, process.env, { ADOM_AI_THREAD: thread }) : process.env;+  execFile(bin, a, { timeout, env }, (err, stdout, stderr) => res({     ok: !err,     out: String(stdout || ''),     errOut: String(stderr || ''),@@ -26,13 +30,14 @@ const which = (bin) => new Promise((res) => { // whatever full name the box actually answers on (pup_*, browser_*, ...). const SUFFIXES = ['open_window', 'navigate', 'screenshot', 'eval', 'status', 'alert_window']; -export async function makeBridge(target) {+export async function makeBridge(target, thread) {+  thread = thread || process.env.ADOM_AI_THREAD || '';   const hasNew = await which('adom-bridge-cli');   const bin = hasNew ? 'adom-bridge-cli' : 'adom-desktop';   const verbs = {};   if (hasNew) {     // Resolve live verb names from bridge_list; a suffix seen there wins.-    const bl = await run(bin, ['bridge_list'], 15000, target);+    const bl = await run(bin, ['bridge_list'], 15000, target, thread);     for (const sfx of SUFFIXES) {       const m = (bl.out + bl.errOut).match(new RegExp('\\b([a-z][a-z0-9]*)_' + sfx + '\\b'));       if (m) verbs[sfx] = m[0];@@ -49,8 +54,8 @@ export async function makeBridge(target) {     verbs,     /// call('open_window', {...}) -> {ok, out, errOut, fail}; args JSON-encoded     call: (suffix, args, timeout = 20000) =>-      run(bin, [verbs[suffix], JSON.stringify(args ?? {})], timeout, target),+      run(bin, [verbs[suffix], JSON.stringify(args ?? {})], timeout, target, thread),     /// plain verb (no JSON args), e.g. bridge_list itself-    raw: (verb, timeout = 15000) => run(bin, [verb], timeout, target),+    raw: (verb, timeout = 15000) => run(bin, [verb], timeout, target, thread),   }; }
capture.mjsadded+120
@@ -0,0 +1,120 @@+#!/usr/bin/env node+// Vitrine capture helper (hero-studio#41): the studio drives the desktop+// browser itself instead of leaving slot math, DPR math, and cropping to the+// driving AI. Opens a dedicated pup session at the right shape, screenshots,+// verifies the shot came from OUR window (sentinel, the #8 wrong-window+// class), and crops to the slot aspect with PIL.+import { execFile } from 'child_process';+import fs from 'fs';+import { makeBridge } from './bridge.mjs';++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));+const SESSION = 'vitrine-shot';++/// captureUrl({ url, selector?, aspect, outFile, target?, thread? })+/// -> { ok, file?, width?, height?, error? }+export async function captureUrl(opts) {+  const { url, selector, aspect, outFile } = opts;+  const bridge = await makeBridge(opts.target || '', opts.thread || '');+  const bad = (error) => ({ ok: false, error });++  const status = await bridge.call('status', {}, 10000);+  if (!status.ok || (status.out + status.errOut).includes('_adom_use_cli_instead')) {+    return bad('the desktop bridge did not answer (' + bridge.generation + '); no host browser to capture with. Capture manually and load it with: hero-studio vitrine --file <shot.png>');+  }+  const hasSession = status.out.includes(SESSION);+  const opened = hasSession+    ? await bridge.call('navigate', { sessionId: SESSION, url }, 30000)+    : await bridge.call('open_window', { sessionId: SESSION, profile: SESSION, url }, 60000);+  if (!opened.ok) return bad('could not open or navigate the capture window');++  const evalIn = async (expr, timeout = 15000) => {+    const r = await bridge.call('eval', { sessionId: SESSION, expr }, timeout);+    return r.ok ? r.out : null;+  };+  let ready = false;+  for (let i = 0; i < 30 && !ready; i++) {+    const r = await evalIn("document.readyState === 'complete' ? 'HS-READY' : 'HS-NO'");+    if (r && r.includes('HS-READY')) ready = true;+    else await sleep(1000);+  }+  if (!ready) return bad('page never became ready at ' + url);++  // Size the window so the VIEWPORT approximates the slot aspect (outer size+  // includes chrome; one correction pass using the measured innerHeight).+  await evalIn('window.resizeTo(1400, Math.round(1400/' + aspect + ') + (window.outerHeight - window.innerHeight))');+  await sleep(1500);+  if (selector) {+    await evalIn("(function(){var el=document.querySelector(" + JSON.stringify(selector) + ");if(el)el.scrollIntoView({block:'center'});return 'ok';})()");+    await sleep(500);+  }++  // Sentinel + geometry: viewport size, DPR, and (optionally) the selector+  // rect, joined as plain numbers to survive CLI JSON wrapping.+  const geomRaw = await evalIn(+    "(function(){var s=document.getElementById('hs-sentinel');if(!s){s=document.createElement('div');" ++    "s.id='hs-sentinel';s.style.cssText='position:fixed;top:0;left:0;width:16px;height:16px;" ++    "background:#ff00fe;z-index:2147483647';document.body.appendChild(s);}" ++    (selector+      ? "var el=document.querySelector(" + JSON.stringify(selector) + ");var b=el?el.getBoundingClientRect():{x:0,y:0,width:window.innerWidth,height:window.innerHeight};"+      : "var b={x:0,y:0,width:window.innerWidth,height:window.innerHeight};") ++    "return 'HSCAP@'+[b.x,b.y,b.width,b.height,window.devicePixelRatio].join(',')+'@PACSH';})()");+  const m = geomRaw && geomRaw.match(/HSCAP@([\d.,\-]+)@PACSH/);+  if (!m) return bad('could not measure the capture region');+  const [gx, gy, gw, gh, dpr] = m[1].split(',').map(Number);+  if (!(gw > 50 && gh > 50)) return bad('capture region measured degenerate (' + gw + 'x' + gh + ')');++  const shot = await bridge.call('screenshot', { sessionId: SESSION }, 45000);+  if (!shot.ok) return bad('screenshot failed');+  let srcPng = null;+  const pathMatch = (shot.out + shot.errOut).match(/[^"\s]+\.png/);+  if (pathMatch && fs.existsSync(pathMatch[0])) srcPng = pathMatch[0];+  if (!srcPng) {+    const b64 = (shot.out + shot.errOut).match(/"(?:data|base64|image|png)"\s*:\s*"([A-Za-z0-9+/=]{200,})"/);+    if (b64) { srcPng = outFile + '.full.png'; fs.writeFileSync(srcPng, Buffer.from(b64[1], 'base64')); }+  }+  if (!srcPng) return bad('screenshot returned neither a readable path nor base64');++  // Crop: sentinel-anchored viewport origin, then the region, then a CENTER+  // crop to the slot aspect (never squeeze).+  const py = [+    'import sys',+    'from PIL import Image',+    'src, out = sys.argv[1], sys.argv[2]',+    'x, y, w, h, dpr, aspect = [float(v) for v in sys.argv[3:9]]',+    'im = Image.open(src).convert("RGB")',+    'W, H = im.size',+    'ox = oy = -1',+    'strip = im.crop((0, 0, min(W, 240), min(H, 480)))',+    'px = strip.load()',+    'for yy in range(strip.size[1]):',+    '    for xx in range(strip.size[0]):',+    '        r, g, b = px[xx, yy]',+    '        if r > 200 and g < 80 and b > 200:',+    '            ox, oy = xx, yy',+    '            break',+    '    if ox >= 0:',+    '        break',+    'if ox < 0:',+    '    print("NO-SENTINEL"); sys.exit(3)',+    'box = (max(0, ox + round(x*dpr)), max(0, oy + round(y*dpr)), min(W, ox + round((x+w)*dpr)), min(H, oy + round((y+h)*dpr)))',+    'im2 = im.crop(box)',+    'cw, ch = im2.size',+    'want = aspect',+    'if cw / ch > want:',+    '    nw = round(ch * want); x0 = (cw - nw) // 2; im2 = im2.crop((x0, 0, x0 + nw, ch))',+    'elif cw / ch < want:',+    '    nh = round(cw / want); y0 = (ch - nh) // 2; im2 = im2.crop((0, y0, cw, y0 + nh))',+    'im2.save(out)',+    'print("%dx%d" % im2.size)',+  ].join('\n');+  const crop = await new Promise((res) => {+    execFile('python3', ['-c', py, srcPng, outFile, String(gx), String(gy), String(gw), String(gh), String(dpr), String(aspect)],+      { timeout: 30000 }, (err, stdout, stderr) => res({ code: err ? (err.code || 1) : 0, out: String(stdout || ''), err: String(stderr || '') }));+  });+  if (srcPng === outFile + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }+  if (crop.code === 3) return bad('the screenshot does not contain the capture window (sentinel missing): wrong-window class, shot discarded');+  if (crop.code !== 0 || !fs.existsSync(outFile)) return bad('crop failed: ' + crop.err.trim().split('\n').pop());+  const [cw, ch] = crop.out.trim().split('x').map(Number);+  return { ok: true, file: outFile, width: cw, height: ch };+}
drive.mjs+75−6
@@ -14,26 +14,66 @@ import path from 'path'; const pi = process.argv.indexOf('--port'); const PORT = Number((pi >= 0 && process.argv[pi + 1]) || process.env.HERO_STUDIO_PORT || 8846); if (pi >= 0) process.argv.splice(pi, 2);+// Global --ai-thread (#39): the settled platform term; travels as a top-level+// aiThread field on every POST. Env fallback is for single-agent boxes only.+const ti = process.argv.indexOf('--ai-thread');+const AI_THREAD = (ti >= 0 && process.argv[ti + 1]) || process.env.ADOM_AI_THREAD || '';+if (ti >= 0) process.argv.splice(ti, 2); const BASE = 'http://localhost:' + PORT + '/'; const args = process.argv.slice(2); const verb = args.shift();  const jf = async (p, body) => {+  if (body !== undefined && !AI_THREAD) {+    console.error('caller_identity_required: this verb changes state, so it needs the ai-thread name.');+    console.error('  Re-run: hero-studio --ai-thread "<name>" ' + (verb || '') + ' ...');+    console.error('  Pick a short name a HUMAN would recognize for THIS conversation (like');+    console.error("  'kicad board review', not 'agent' or a UUID) and reuse it on every call.");+    process.exit(1);+  }   const r = await fetch(BASE + p, body === undefined ? undefined : {     method: 'POST',     headers: { 'Content-Type': 'application/json' },-    body: JSON.stringify(body),+    body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, body)),   });   const j = await r.json().catch(() => ({}));-  if (!r.ok) throw new Error(p + ' -> ' + r.status);+  if (!r.ok) {+    if (j && j.errorCode === 'caller_identity_required') throw new Error(j.error + ' ' + j.hint);+    throw new Error(p + ' -> ' + r.status);+  }   return j; }; const die = (msg) => { console.error(msg); process.exit(1); };  try {   if (verb === 'vitrine') {+    const ui = args.indexOf('--from-url');+    if (ui >= 0) {+      // #41: the studio drives the capture itself: slot math, DPR, sentinel+      // verification, and aspect cropping all happen here, not in the caller.+      const url2 = args[ui + 1];+      if (!url2) die('usage: hero-studio vitrine --from-url <url> [--selector <css>] [--full] [--cover]');+      const si = args.indexOf('--selector');+      const st = await jf('state');+      const slot = st.slotSize || { w: 1280, h: 960 };+      const { captureUrl } = await import('./capture.mjs');+      const os = await import('os');+      const tmp = path.join(os.tmpdir(), 'hs-vitrine-' + Date.now() + '.png');+      console.error('capturing ' + url2 + ' at slot aspect ' + (slot.w / slot.h).toFixed(2) + ' (' + slot.w + 'x' + slot.h + ') via the desktop bridge...');+      const cap = await captureUrl({+        url: url2, selector: si >= 0 ? args[si + 1] : null,+        aspect: slot.w / slot.h, outFile: tmp,+        target: process.env.ADOM_TARGET || '', thread: AI_THREAD,+      });+      if (!cap.ok) die('ERROR: ' + cap.error);+      const out2 = await jf('vitrine-file', { path: tmp, full: args.includes('--full'), cover: args.includes('--cover'), chrome: '' });+      if (!out2.ok) die('ERROR: ' + out2.error);+      console.log(JSON.stringify({ ok: true, captured: cap.width + 'x' + cap.height, bytes: out2.bytes }));+      console.error('vitrine captured and loaded; the open studio updates within a second');+      process.exit(0);+    }     const fi = args.indexOf('--file');-    if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake]');+    if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]');     const file = path.resolve(args[fi + 1]);     const ci = args.indexOf('--chrome');     const chrome = ci >= 0 ? args[ci + 1] : '';@@ -76,7 +116,7 @@ try {       die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');     }     const r = await fetch(BASE + 'state', {-      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, patch)),     });     const out = await r.json().catch(() => ({}));     if (!r.ok || out.ok === false) {@@ -105,7 +145,7 @@ try {     // named family design systems (#34.2); no arg lists what exists     const name = args.find((a) => !a.startsWith('--'));     const r = await fetch(BASE + 'preset', {-      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '' }),+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '', aiThread: AI_THREAD }),     });     const out = await r.json().catch(() => ({}));     if (!r.ok || out.ok === false) {@@ -114,6 +154,35 @@ try {     }     console.log(JSON.stringify(out));     console.error('preset ' + out.applied + ' applied; the open studio updates within a second');+  } else if (verb === 'ls') {+    // the instance registry (#39): live studios, liveness-checked, dead+    // entries pruned, version drift vs the on-disk package flagged+    const fsm = await import('fs');+    const os = await import('os');+    const dir = path.join(os.homedir(), '.adom', 'instances', 'hero-studio');+    const diskVer = JSON.parse(fsm.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;+    const rows = [];+    let files = [];+    try { files = fsm.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}+    for (const f of files) {+      let e;+      try { e = JSON.parse(fsm.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }+      try { process.kill(e.pid, 0); } catch { try { fsm.unlinkSync(path.join(dir, f)); } catch {}; continue; }+      const idleMin = Math.round((Date.now() - new Date(e.lastActivity || e.startedAt).getTime()) / 60000);+      rows.push({ port: e.port, thread: e.thread, task: e.task, version: e.version, idleMinutes: idleMin, pid: e.pid, drift: e.version !== diskVer });+    }+    rows.sort((a, b) => a.port - b.port);+    console.log(JSON.stringify({ instances: rows, diskVersion: diskVer }, null, 2));+    for (const r2 of rows) {+      if (r2.drift) console.error('WARN: port ' + r2.port + ' runs ' + r2.version + ', disk has ' + diskVer + '. Shut it down (hero-studio stop --port ' + r2.port + ') and respawn.');+    }+    if (!rows.length) console.error('no live instances; hero-studio --ai-thread "<name>" starts one (or pose does)');+  } else if (verb === 'fit') {+    // auto-fit the title (#40): solved in the open surface with real metrics+    const out = await jf('fit', {});+    if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n  hint: ' + out.hint : ''));+    console.log(JSON.stringify(out));+    console.error('fit requested; read state.fontSize.title in about a second');   } else if (verb === 'instances') {     // every live studio on this box (#26): scan the port neighborhood     const ports = new Set([PORT]);@@ -151,7 +220,7 @@ try {     console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length }));     console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)');   } else {-    die('verbs: pose | vitrine | set | preset | state | toast | reroll | changes | bg | compose | instances | stop');+    die('verbs: pose | vitrine | set | preset | fit | state | toast | reroll | changes | bg | compose | ls | instances | stop');   } } catch (e) {   die('ERROR: ' + e.message + '\n  hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');
package.json+2−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.16.2",+  "version": "0.17.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. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [@@ -81,6 +81,7 @@     "drive.mjs",     "doctor.mjs",     "bridge.mjs",+    "capture.mjs",     "bin/hero-studio",     "public/**",     "data/**",
page.json+2−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.16.2",+  "version": "0.17.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. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [@@ -82,6 +82,7 @@     "drive.mjs",     "doctor.mjs",     "bridge.mjs",+    "capture.mjs",     "bin/hero-studio",     "public/**",     "data/**",
pose.mjs+49−5
@@ -23,10 +23,30 @@ 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-vitrine', 'no-fallback', 'keep-alive']); for (let i = 0; i < args.length; i++) {-  if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[i + 1] ?? '', i++;-  else pos.push(args[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 [--title T] [--brief B] [--type app|skill] [--timeout SECS]');@@ -63,6 +83,11 @@ if (preflight.renderer === 'pup') { }  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();@@ -82,7 +107,26 @@ const sh = (cmd, a, timeout = 20000) => new Promise((res) => { // 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);-const portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT);+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 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 {}@@ -115,7 +159,7 @@ if (state && !portPinned) {   } } if (!state) {-  spawn(process.execPath, [path.join(DIR, 'server.js'), '--port', String(port)], { detached: true, stdio: 'ignore', env: process.env }).unref();+  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 {}@@ -196,7 +240,7 @@ 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);+  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);
public/index.html+185−28
@@ -660,7 +660,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh       <button class="opt" data-v="bleed-corner-tr">Corner upper-right</button>       <button class="opt" data-v="contained">Contained</button>       <button class="opt" data-v="mirror">Mirror (text right)</button>-      <button class="opt" data-v="showcase" data-tip="Adom-USB style: the vitrine gets ~65% of the hero behind a gradient scrim">Showcase (65% vitrine)</button>+      <button class="opt" data-v="showcase" data-tip="Adom-USB style: a large vitrine behind a gradient scrim. The title is STILL the message; the scrim exists so the text column keeps its white space">Showcase</button>     </div>     <div class="hint">The shot may run under the lower third; the overlay scrim covers it. That is the new rule.</div> @@ -935,7 +935,12 @@ let comps = []; let state = null;  const $ = (id) => document.getElementById(id);-const send = (patch) => fetch('state', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(patch) }).catch(()=>{});+// Every state-changing POST from this page is attributed to the server's+// owning ai-thread (#39): the page IS that instance's surface, and the human+// acting through it acts within that thread's session.+const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});+const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });+const send = (patch) => post('state', patch).catch(() => {});  function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); } // the LAUNCH spelling for display + bake: the wiki's public name is hardware@@ -1329,7 +1334,7 @@ $('sel-page').addEventListener('change', e => {   send({ page: v });   // #30: dropdown rows are pose-time snapshots; pull the live page so a   // just-renamed title or brief cannot be ratified stale.-  fetch('refresh-page?page=' + encodeURIComponent(v), { method: 'POST' })+  post('refresh-page?page=' + encodeURIComponent(v))     .then(r => r.json())     .then(j => {       if (!j || !j.ok) return;@@ -1398,7 +1403,7 @@ function runGenerate() {   const b = $('btn-generate');   b.disabled = true; b.textContent = 'Generating...';   toast('Rendering the hero on the server', 'info');-  fetch('generate', { method: 'POST' }).then(r => r.json()).then(g => {+  post('generate').then(r => r.json()).then(g => {     b.disabled = false; b.textContent = 'Generate hero PNG';     if (g.ok) handleGen(g); // success toast arrives via the live channel     else toast('Generate failed: ' + (g.error || 'unknown') + (g.hint ? '. ' + g.hint : ''), 'error');@@ -1484,7 +1489,7 @@ $('slot-size').addEventListener('click', () => { }); $('vitrine-clear').addEventListener('click', () => send({ vitrine: { src: '', full: false } })); $('vitrine-pull').addEventListener('click', () => {-  fetch('vitrine-from-page?page=' + encodeURIComponent(state.page), { method: 'POST' })+  post('vitrine-from-page?page=' + encodeURIComponent(state.page))     .then(r => r.json())     .then(j => handleToast({ text: j.ok ? 'Vitrine loaded: ' + j.picked : 'No page screenshot: ' + j.error, kind: j.ok ? 'success' : 'warn', seq: Date.now() }))     .catch(() => {});@@ -1493,11 +1498,11 @@ $('vitrine-pull').addEventListener('click', () => { /* verification walkthrough: spotlight each checklist item in place */ const TOUR_STEPS = [   { key: 'title', el: 'el-title', title: 'This is your title',-    body: 'It bakes into the image at this exact size. Make sure the words are right and it reads at dock size.' },+    body: 'A hero gets across exactly ONE message, and this is it: the title, ideally approaching one word. Six messages equal zero messages. It bakes at this exact size; make sure it reads at dock size.' },   { key: 'subtitle', el: 'el-sub', title: 'This is your subtitle',-    body: 'The one-line value prop. Make sure you are happy with the wording and it is legible on this background.' },+    body: 'Deliberately small BECAUSE it is assumed nobody reads it. If the design only works when someone reads this line, the hero has failed. Check the wording anyway.' },   { key: 'vitrine', el: 'vitrine-img', fallbackEl: 'circuitcanvas', winSel: '.window', title: 'This is your vitrine',-    body: 'The app window. It should show YOUR app, not a generic chart. If it looks generic, tell your AI to capture a real screenshot at the slot size shown in the left panel.' },+    body: 'Supporting evidence, NOT the message; the title is the message. It should show YOUR app, not a generic chart, and it should leave white space: negative space is what makes the one message land.' },   { key: 'type', el: 'pill-tr', title: 'This is your type pill',     body: 'app / skill / hardware / bootstrap (hardware is the launch name; component is its legacy spelling and survives as a kind). AIs pick this wrong often; if it says the wrong thing, yell at yours now, before it bakes.' }, ];@@ -1696,6 +1701,44 @@ function runLint() {     } catch {}   } +  // 6b. vitrine coverage (#43): the title is the message, the vitrine is+  // supporting evidence. When the vitrine swallows the hero, the hierarchy+  // inverts and white space (what makes the one message land) is gone.+  if (vitrine) {+    const iw = Math.max(0, Math.min(vitrine.right, H.width) - Math.max(vitrine.left, 0));+    const ih = Math.max(0, Math.min(vitrine.bottom, H.height) - Math.max(vitrine.top, 0));+    const cov = (iw * ih) / (H.width * H.height);+    if (cov > 0.72) {+      items.push({ id: 'vitrine-coverage', level: 'warn', el: null, msg: 'the vitrine covers ' + Math.round(cov * 100) + '% of the hero. The title is the ONE message this image sends; the vitrine is supporting evidence. Pick a layout that gives the title its white space' });+    }+  }++  // 6c. cover-crop accounting (#41): a cover fit silently discards source+  // pixels; say how much, and check the crop lines for beheaded content.+  const vimg = $('vitrine-img');+  if (vit2.src && !vit2.full && vit2.cover && vimg && vimg.naturalWidth > 0) {+    const win2 = hero.querySelector('.appbody');+    if (win2 && win2.offsetParent) {+      const wb = win2.getBoundingClientRect();+      const arImg = vimg.naturalWidth / vimg.naturalHeight;+      const arSlot = wb.width / wb.height;+      const discard = 1 - Math.min(arImg / arSlot, arSlot / arImg);+      if (discard > 0.25) {+        const axis = arImg > arSlot ? 'left and right edges' : 'top and bottom edges';+        items.push({ id: 'cover-discard', level: 'warn', el: 'vitrine-img', msg: 'cover fit discards ' + Math.round(discard * 100) + '% of the source screenshot (' + axis + '). Check nothing that matters was cropped out; a screenshot captured AT the slot aspect (left panel size) loses nothing' });+      }+      scanCropEdges(vimg, arSlot);+      if (cropScan.status === 'done' && cropScan.cut) {+        items.push({ id: 'crop-edge-cut', level: 'warn', el: 'vitrine-img', msg: 'content appears cut mid-element at the ' + cropScan.where + ' crop line (edge-density heuristic): logos or headings beheaded by a crop read as broken. Recapture at the slot aspect or move the crop' });+      }+    }+  }++  // 6d. multi-message import (#43): one hero, one message.+  if (vit2.full && vit2.src && bandScan.status === 'done' && bandScan.busyCells >= 6) {+    items.push({ id: 'import-multi-message', level: 'warn', el: 'fullimport-img', msg: 'the imported image reads as ' + bandScan.busyCells + ' of 12 zones text/graphic-dense: that is several competing messages, and a hero sends ONE. The title should be the message; white space is what makes it land' });+  }+   // 7. published headroom (#28): how much calm space is left under the lowest   // text before the marquee band, in EXPORT pixels; informational.   if (texts.length && state.mode === 'app') {@@ -1777,32 +1820,49 @@ $('mqo-prompt-copy').addEventListener('click', async (e) => {    pixels, so the class of hero most likely to violate the band skipped every    check. A cheap edge-density scan of the bottom 24% flags text-like structure    in the band; it is a heuristic and says so. */-let bandScan = { key: '', status: 'idle', texty: false };+let bandScan = { key: '', status: 'idle', texty: false, busyCells: 0, cells: 12 };+function edgeDensity(g, sx, sy, sw, sh, w, h) {+  // draw a region to a small canvas and return horizontal edge density+  g.canvas.width = w; g.canvas.height = h;+  g.drawImage(g._img, sx, sy, sw, sh, 0, 0, w, h);+  const d = g.getImageData(0, 0, w, h).data;+  let edges = 0, n = 0;+  for (let y = 0; y < h; y++) {+    for (let x = 1; x < w; x++) {+      const i = (y * w + x) * 4, j = i - 4;+      const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;+      const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;+      if (Math.abs(a - b) > 60) edges++;+      n++;+    }+  }+  return n ? edges / n : 0;+} function scanImportBand(src) {   const key = src.length + ':' + src.slice(40, 72);   if (bandScan.key === key) return;-  bandScan = { key, status: 'pending', texty: false };+  bandScan = { key, status: 'pending', texty: false, busyCells: 0, cells: 12 };   const img = new Image();   img.onload = () => {     try {+      const g = document.createElement('canvas').getContext('2d');+      g._img = img;+      // 1. the calm-band check (bottom 24%), unchanged       const w = Math.min(800, img.width);       const bandH = Math.max(10, Math.round(img.height * 0.24 * (w / img.width)));-      const c = document.createElement('canvas');-      c.width = w; c.height = bandH;-      const g = c.getContext('2d');-      g.drawImage(img, 0, img.height * 0.76, img.width, img.height * 0.24, 0, 0, w, bandH);-      const d = g.getImageData(0, 0, w, bandH).data;-      let edges = 0, n = 0;-      for (let y = 0; y < bandH; y++) {-        for (let x = 1; x < w; x++) {-          const i = (y * w + x) * 4, j = i - 4;-          const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;-          const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;-          if (Math.abs(a - b) > 60) edges++;-          n++;+      bandScan.texty = edgeDensity(g, 0, img.height * 0.76, img.width, img.height * 0.24, w, bandH) > 0.0015;+      // 2. message-block grid (#43): a hero sends ONE message; count how many+      // zones of the upper 76% read as text/graphic-dense. 4x3 grid, cheap.+      let busy = 0;+      for (let gy = 0; gy < 3; gy++) {+        for (let gx = 0; gx < 4; gx++) {+          const dens = edgeDensity(g,+            img.width * gx / 4, img.height * 0.76 * gy / 3,+            img.width / 4, img.height * 0.76 / 3, 200, 100);+          if (dens > 0.003) busy++;         }       }-      bandScan.texty = n > 0 && (edges / n) > 0.0015;+      bandScan.busyCells = busy;       bandScan.status = 'done';     } catch { bandScan.status = 'failed'; }     scheduleLint();@@ -1811,6 +1871,102 @@ function scanImportBand(src) {   img.src = src; } +/* crop-line scan (#41): sample thin strips just inside the visible window of+   a cover-cropped vitrine; text straddling the crop line = beheaded content */+let cropScan = { key: '', status: 'idle', cut: false, where: '' };+function scanCropEdges(vimg, arSlot) {+  const key = vimg.src.length + ':' + arSlot.toFixed(3);+  if (cropScan.key === key) return;+  cropScan = { key, status: 'pending', cut: false, where: '' };+  try {+    const g = document.createElement('canvas').getContext('2d');+    g._img = vimg;+    const iw = vimg.naturalWidth, ih = vimg.naturalHeight;+    const arImg = iw / ih;+    let cut = false, where = '';+    if (arImg > arSlot) {+      const visW = ih * arSlot;+      const cutL = (iw - visW) / 2;+      const strip = Math.max(8, iw * 0.02);+      if (edgeDensity(g, cutL, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = 'left'; }+      if (edgeDensity(g, cutL + visW - strip, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = where ? 'left and right' : 'right'; }+    } else if (arImg < arSlot) {+      const visH = iw / arSlot;+      const cutT = (ih - visH) / 2;+      const strip = Math.max(8, ih * 0.02);+      if (edgeDensity(g, 0, cutT, iw, strip, 300, 24) > 0.004) { cut = true; where = 'top'; }+      if (edgeDensity(g, 0, cutT + visH - strip, iw, strip, 300, 24) > 0.004) { cut = true; where = where ? 'top and bottom' : 'bottom'; }+    }+    cropScan.cut = cut; cropScan.where = where; cropScan.status = 'done';+  } catch { cropScan.status = 'failed'; }+}++/* auto-fit (#40): the largest title size that keeps <=3 lines, positive+   headroom, and no clipping, measured with the REAL layout: sweep the CSS+   var coarse-to-fine, read the boxes, restore. Runs on the fit SSE event+   (drive verb + preset re-fit) and on headline edits; never in render mode. */+function solveTitleFit(maxCqw) {+  const tEl = $('el-title');+  if (!tEl || !tEl.offsetParent || document.body.dataset.render !== undefined) return null;+  const H = hero.getBoundingClientRect();+  if (H.width < 60) return null;+  const bandTop = H.top + H.height * 0.76;+  const measure = (v) => {+    hero.style.setProperty('--fs-title', v + 'cqw');+    void tEl.offsetHeight;+    const b = tEl.getBoundingClientRect();+    const lh = parseFloat(getComputedStyle(tEl).lineHeight) || 1;+    return { b, lines: Math.max(1, Math.round(b.height / lh)) };+  };+  const inHero = (b) => b.bottom < bandTop && b.right <= H.right + 1 && b.left >= H.left - 1 && b.top >= H.top - 1;+  const fits = (v) => { const m = measure(v); return m.lines <= 3 && inHero(m.b); };+  const fitsLoose = (v) => inHero(measure(v).b);+  const hi = Math.min(maxCqw || 13, 13);+  const sweep = (pred) => {+    let best = null;+    for (let v = hi; v >= 5 && best == null; v = Math.round((v - 0.5) * 10) / 10) {+      if (pred(v)) best = v;+    }+    if (best != null && best < hi) {+      for (let v = Math.min(hi, best + 0.4); v > best; v = Math.round((v - 0.1) * 10) / 10) {+        if (pred(v)) { best = v; break; }+      }+    }+    return best;+  };+  let best = sweep(fits);+  let loose = false;+  if (best == null) {+    // Nothing satisfies <=3 lines at any legible size: the headline itself is+    // the problem. Relax to the HARD constraints only (inside the hero, above+    // the band) so the pose is at least never broken, and say so.+    loose = true;+    best = sweep(fitsLoose);+  }+  const cur = (state.fontSize || {}).title || 8;+  hero.style.setProperty('--fs-title', cur + 'cqw');+  if (best == null) return null;+  hero.style.setProperty('--fs-title', best + 'cqw');+  void tEl.offsetHeight;+  const lines = Math.max(1, Math.round(tEl.getBoundingClientRect().height / (parseFloat(getComputedStyle(tEl).lineHeight) || 1)));+  hero.style.setProperty('--fs-title', cur + 'cqw');+  return { size: best, loose, lines };+}+function runFit(maxCqw) {+  const r = solveTitleFit(maxCqw);+  if (r == null) {+    handleToast({ text: 'fit: no title size in 5-13cqw keeps this headline inside the hero for this layout; shorten the headline or change layout', kind: 'warn', seq: Date.now() });+    return;+  }+  const cur = (state.fontSize || {}).title || 8;+  if (Math.abs(r.size - cur) >= 0.05) send({ fontSize: { title: r.size } });+  if (r.loose) {+    handleToast({ text: 'fit: this headline cannot make 3 lines at any legible size; set ' + r.size + 'cqw (' + r.lines + ' lines, still clear of the band). SHORTER COPY READS BIGGER: the title is the one message, aim for a few words', kind: 'warn', seq: Date.now() });+  } else if (Math.abs(r.size - cur) >= 0.05) {+    handleToast({ text: 'fit: title ' + r.size + 'cqw (largest size with <=3 lines and positive headroom for this headline + layout)', kind: 'info', seq: Date.now() });+  }+}+ /* the stagebar (#21/#23): explicit dynamic on/off, keyboard D, legend off-stage */ $('sb-png').addEventListener('click', () => send({ marqueePreview: false })); $('sb-mq').addEventListener('click', () => send({ marqueePreview: true }));@@ -1859,7 +2015,7 @@ function pushPageField() {   const [owner, slug] = (state.page || '/').split('/');   const body = { owner, slug, title: $('pf-title').value.trim(), brief: $('pf-brief').value.trim(),     tags: $('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean) };-  fetch('api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })+  post('api/pages', body)     .then(() => fetch('api/pages')).then(r => r.json()).then(rows => { pages = rows; render(); refreshChangesBadge(); })     .catch(() => {}); }@@ -1868,7 +2024,7 @@ for (const id of ['pf-title', 'pf-brief', 'pf-tags']) {   $(id).addEventListener('blur', () => { pfEditing = false; pushPageField(); }); } $('pf-apply').addEventListener('click', async () => {-  const r = await (await fetch('apply-page', { method: 'POST' })).json().catch(() => ({}));+  const r = await (await post('apply-page')).json().catch(() => ({}));   if (!r.ok) { handleToast({ text: r.error || 'nothing to apply', kind: 'warn', seq: Date.now() }); return; }   handleToast({     text: r.applied === 'wiki' ? 'Page edits applied to the wiki (' + r.fields.join(', ') + ')'@@ -1969,7 +2125,7 @@ function drawCircuitry() {     g.stroke();   } }-$('btn-reroll').addEventListener('click', () => { fetch('reroll', { method: 'POST' }).catch(() => {}); });+$('btn-reroll').addEventListener('click', () => { post('reroll').catch(() => {}); });  /* image-zone type sizes: instant local feedback, debounced send */ let fsTimer = null;@@ -1977,7 +2133,7 @@ let hlTimer = null; $('headline-input').addEventListener('input', e => {   clearTimeout(hlTimer);   const v = e.target.value;-  hlTimer = setTimeout(() => send({ headline: v }), 350);+  hlTimer = setTimeout(() => { send({ headline: v }); setTimeout(() => runFit(null), 250); }, 350); }); for (const [id, key] of [['fs-title', 'title'], ['fs-sub', 'sub'], ['fs-desc', 'desc'], ['fs-leading', 'leading'], ['fs-gap', 'gap']]) {   $(id).addEventListener('input', e => {@@ -2020,6 +2176,7 @@ Promise.all([   }   const es = new EventSource('events');   es.addEventListener('state', e => { state = JSON.parse(e.data); render(); });+  es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });   es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });   es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });   // poll fallback in case the proxy buffers the event stream
server.js+131−10
@@ -4,11 +4,30 @@ const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process'); -// Port: --port flag > HERO_STUDIO_PORT env > 8846. Multiple instances on-// separate ports = concurrent studios (hero-studio#15): one thread's pose-// cannot clobber another's when each pose owns its own server.+// Port: --port flag > HERO_STUDIO_PORT env > 8846 ('auto' = bind an OS-picked+// free port). Multiple instances on separate ports = concurrent studios+// (hero-studio#15): one thread's pose cannot clobber another's. const portArg = process.argv.indexOf('--port');-const PORT = Number((portArg >= 0 && process.argv[portArg + 1]) || process.env.HERO_STUDIO_PORT || 8846);+const portRaw = String((portArg >= 0 && process.argv[portArg + 1]) || process.env.HERO_STUDIO_PORT || 8846);+let PORT = portRaw === 'auto' ? 0 : Number(portRaw);++// The ai-thread name (#39, the settled platform term: see the+// app-creator-ai-thread-name skill): required at start, self-reported+// attribution only, never authorization. Precedence: --ai-thread flag, then+// ADOM_AI_THREAD env (single-agent containers only).+const argVal = (f) => { const i = process.argv.indexOf(f); return i >= 0 ? process.argv[i + 1] : undefined; };+const AI_THREAD = argVal('--ai-thread') || process.env.ADOM_AI_THREAD || '';+if (!AI_THREAD) {+  console.error('caller_identity_required: hero-studio requires the ai-thread name at start.');+  console.error('  Re-run: hero-studio --ai-thread "<name>" [--port N]');+  console.error('  Pick a short name a HUMAN would recognize for THIS conversation');+  console.error('  (like "kicad board review", not "agent" or a UUID) and reuse it on every call.');+  process.exit(1);+}+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'); 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@@ -133,6 +152,7 @@ function merge(dst, src) { }  function readBody(req, cb) {+  if (req._body !== undefined) return cb(null, req._body);   let b = '';   req.on('data', (c) => { b += c; });   req.on('end', () => {@@ -143,8 +163,11 @@ function readBody(req, cb) { // Snapshot persistence (hero-studio#32.9): a cache-refresh restart used to // wipe the reviewer's ticked checklist, staged imports, and the change ledger. // Every state change debounce-writes a snapshot; boot restores it when fresh.-const SNAP = path.join(__dirname, 'generated', 'state-snapshot.json');-try { fs.mkdirSync(path.join(__dirname, 'generated'), { recursive: true }); } catch {}+// Per-thread snapshot (#39): 13 instances sharing one snapshot file clobbered+// each other and swapped approval records between pages; the thread key makes+// parallel instances isolated at the persistence layer, not just in memory.+const SNAP = path.join(INSTANCES_DIR, 'snapshots', THREAD_SLUG + '.json');+try { fs.mkdirSync(path.join(INSTANCES_DIR, 'snapshots'), { recursive: true }); } catch {} try {   const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8'));   if (snap && snap.state && Date.now() - (snap.ts || 0) < 24 * 3600 * 1000) {@@ -161,23 +184,91 @@ function saveSnapshot() {   }, 400); } +// Instance registry (#39): ~/.adom/instances/hero-studio/<thread>.json makes+// every live studio discoverable (`hero-studio ls`), reusable, and auditable.+const REG_FILE = path.join(INSTANCES_DIR, THREAD_SLUG + '.json');+function writeRegistry() {+  try {+    fs.writeFileSync(REG_FILE, JSON.stringify({+      pid: process.pid, port: PORT, thread: AI_THREAD, task: state.page,+      version: PKG_VERSION, startedAt: STARTED_AT, lastActivity: new Date(lastActivity).toISOString(),+    }, null, 1));+  } catch {}+}+function deregisterInstance() { try { fs.unlinkSync(REG_FILE); } catch {} }+const STARTED_AT = new Date().toISOString();+let lastActivity = Date.now();++function removeWebviewTab() {+  const name = PORT === 8846 ? 'Hero Studio' : 'Hero Studio :' + PORT;+  try {+    execFile('adom-cli', ['hydrogen', 'webview', 'remove-tab', '--name', name], { timeout: 8000 }, () => {});+  } catch {}+}++// Idle reaper (#38): agents forget, so the server manages itself. Any HTTP+// touch resets the clock; a slowly-finished pose survives days, an abandoned+// one is gone in TTL_HOURS with a lossless snapshot (#39 restores it).+function reap() {+  if (generating) return; // finish pending renders; next tick reaps+  logLine('info', 'idle ' + TTL_HOURS + 'h: reaping (snapshot saved; restart with the same --ai-thread to resume)');+  try { fs.writeFileSync(SNAP, JSON.stringify({ ts: Date.now(), state, changes })); } catch {}+  deregisterInstance();+  removeWebviewTab();+  setTimeout(() => process.exit(0), 300);+}+if (!KEEP_ALIVE) {+  setInterval(() => {+    writeRegistry();+    if (Date.now() - lastActivity > TTL_HOURS * 3600e3) reap();+  }, 60e3).unref();+}+process.on('SIGTERM', () => { deregisterInstance(); process.exit(0); });+process.on('SIGINT', () => { deregisterInstance(); process.exit(0); });+ const MIME = {   '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',   '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',   '.woff2': 'font/woff2', }; +// Read-only endpoints stay open (observation and debugging are never gated);+// every state-changing POST must carry the ai-thread name.+const THREAD_EXEMPT = new Set(['/console', '/events']); const server = http.createServer((req, res) => {   const url = new URL(req.url, 'http://x');   const p = url.pathname;+  lastActivity = Date.now();+  if (req.method === 'POST' && !THREAD_EXEMPT.has(p)) {+    return readBody(req, (err, body) => {+      if (err) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'bad json' })); }+      const name = (body && body.aiThread) || url.searchParams.get('aiThread') || '';+      if (!name) {+        res.writeHead(400, { 'Content-Type': 'application/json' });+        return res.end(JSON.stringify({+          ok: false, errorCode: 'caller_identity_required',+          error: 'This app requires every AI caller to give its ai-thread name.',+          hint: 'Re-run with the global flag: hero-studio --ai-thread "<name>" <verb>, or add a top-level "aiThread" field if you are POSTing directly. Pick a short name a HUMAN would recognize for THIS conversation (like \'kicad board review\', not \'agent\' or a UUID) and reuse it on every call to every Adom tool.',+        }));+      }+      req.aiThread = String(name);+      req._body = body;+      logLine('info', 'thread "' + req.aiThread + '": POST ' + p);+      route(req, res, url, p);+    });+  }+  route(req, res, url, p);+});++function route(req, res, url, p) {    if (p === '/state' && req.method === 'GET') {     res.writeHead(200, { 'Content-Type': 'application/json' });-    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION })));+    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD })));   }   if (p === '/version') {     res.writeHead(200, { 'Content-Type': 'application/json' });-    return res.end(JSON.stringify({ version: PKG_VERSION, port: PORT }));+    return res.end(JSON.stringify({ version: PKG_VERSION, port: PORT, thread: AI_THREAD, ttlHours: KEEP_ALIVE ? null : TTL_HOURS, startedAt: STARTED_AT }));   }   if (p === '/state' && req.method === 'POST') {     return readBody(req, (err, body) => {@@ -340,6 +431,9 @@ const server = http.createServer((req, res) => {       notePoseDiff(before);       logLine('info', 'preset applied: ' + name);       broadcast('state', state);+      // #40: a preset's title size is a ceiling, not a constant; blind 12cqw+      // in a showcase column took a real pose from 3 warns to 2 hard fails.+      broadcast('fit', { max: (PRESETS[name].fontSize || {}).title || null });       res.writeHead(200, { 'Content-Type': 'application/json' });       res.end(JSON.stringify({ ok: true, applied: name, patch: PRESETS[name] }));     });@@ -347,6 +441,8 @@ const server = http.createServer((req, res) => {   if (p === '/shutdown' && req.method === 'POST') {     // instance lifecycle (#26): a polite stop verb so zombies don't accumulate     logLine('info', 'shutdown requested');+    try { fs.writeFileSync(SNAP, JSON.stringify({ ts: Date.now(), state, changes })); } catch {}+    deregisterInstance();     res.writeHead(200, { 'Content-Type': 'application/json' });     res.end(JSON.stringify({ ok: true, port: PORT }));     setTimeout(() => process.exit(0), 150);@@ -399,6 +495,15 @@ const server = http.createServer((req, res) => {     });     return;   }+  if (p === '/fit' && req.method === 'POST') {+    if (!clients.size) {+      res.writeHead(200, { 'Content-Type': 'application/json' });+      return res.end(JSON.stringify({ ok: false, error: 'no open studio surface to measure in', hint: 'fit solves with real text metrics, so it needs the studio page open (pose opens one); the result lands in state.fontSize.title within a second' }));+    }+    broadcast('fit', { max: (req._body && req._body.max) || null });+    res.writeHead(200, { 'Content-Type': 'application/json' });+    return res.end(JSON.stringify({ ok: true, hint: 'solving in the open surface; read state.fontSize.title (and the fit toast) in about a second' }));+  }   if (p === '/reroll' && req.method === 'POST') {     state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647;     broadcast('state', state);@@ -655,7 +760,7 @@ const server = http.createServer((req, res) => {     res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream' });     res.end(buf);   });-});+}  server.on('error', (err) => {   if (err && err.code === 'EADDRINUSE') {@@ -665,4 +770,20 @@ server.on('error', (err) => {   }   throw err; });-server.listen(PORT, () => console.log('hero-studio on ' + PORT));++// Reuse instead of respawn (#39): a live instance for THIS thread means use+// it, not another spawn; the forgotten-studio class dies here.+try {+  const prev = JSON.parse(fs.readFileSync(REG_FILE, 'utf8'));+  if (prev && prev.pid && prev.pid !== process.pid) {+    process.kill(prev.pid, 0); // throws if dead+    console.log('OK: already running on port ' + prev.port + ' (started ' + prev.startedAt + ', task ' + (prev.task || '?') + '). Use that instance.');+    process.exit(0);+  }+} catch {}++server.listen(PORT, () => {+  PORT = server.address().port;+  writeRegistry();+  console.log('hero-studio on ' + PORT + ' (thread "' + AI_THREAD + '", ttl ' + (KEEP_ALIVE ? 'off' : TTL_HOURS + 'h') + ')');+});