app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "pup-bridge",- "version": "2.0.321",+ "version": "2.0.322", "type": "app", "description": "The pup SKILL PACK \u2014 the AI skills that teach a cloud/container AI how to drive the Adom Desktop Puppeteer (pup) bridge (browser_* verbs): the everyday `pup` skill plus focused sub-skills for windows/sessions/tabs, screenshots+recording, browsers+Chrome install, and driving the Adom wiki. Installs into ~/.claude/skills + ~/.codex/skills. DOCS ONLY \u2014 this is NOT the bridge runtime. The bridge itself (the Node program that drives the browser) runs on the DESKTOP: Adom Desktop bundles it and auto-installs/updates it, and it's published separately on the Releases tab as adom-bridge-puppeteer-v<ver>.zip. The pkg version is kept in lockstep with the Release version so the page's max version row always carries BOTH a pkg tarball (skills) and a runtime zip (bridge) \u2014 a skills-only or runtime-only bump on the top row breaks constraint installs. TL;DR: pkg install = the skills for your AI; the bridge = the Release zip, which Adom Desktop manages for you.", "keywords": [
page.json+1−1@@ -4,7 +4,7 @@ "slug": "pup-bridge", "title": "Pup - Puppeteer Bridge", "brief": "pup is the AI's own browser: a real, full Chrome on the user's desktop that the AI fully controls (a sandbox, not the user's signed-in browser). Rides Bridge; pup_* verbs open windows and tabs, navigate, screenshot, and eval JS.",- "version": "2.0.321",+ "version": "2.0.322", "tags": [ "skills", "pup",
tools/pup-dashboard+162−117@@ -323,12 +323,36 @@ async function load(){ tr.disabled=false; tr.textContent='Try it';} }); })();-document.getElementById('demo').onclick=async function(){- const b=this,o=document.getElementById('out'); b.disabled=true; b.textContent='Running…'; o.hidden=false; o.textContent='';- try{ const r=await fetch('api/demo',{method:'POST'}); const d=await r.json(); o.textContent=(d.lines||[]).join('\\n'); }- catch(e){ o.textContent='demo failed: '+e; }- b.disabled=false; b.textContent='Run the demo';-};+(function(){+ const demoBtn=document.getElementById('demo'), tp=document.getElementById('transport');+ const ctl=(action)=>fetch('api/demo/control',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action})}).catch(function(){});+ document.getElementById('tprev').onclick=()=>ctl('prev');+ document.getElementById('tnext').onclick=()=>ctl('next');+ document.getElementById('tstop').onclick=()=>ctl('stop');+ let paused=false;+ document.getElementById('tplay').onclick=()=>{ paused=!paused; ctl(paused?'pause':'resume'); };+ let poll=null;+ function stopPoll(){ if(poll){clearInterval(poll);poll=null;} tp.hidden=true; demoBtn.disabled=false; demoBtn.textContent='Run the demo'; }+ async function tick(){+ try{+ const d=await (await fetch('api/demo/state',{cache:'no-store'})).json();+ if(!d.running){ stopPoll(); return; }+ tp.hidden=false; demoBtn.disabled=true; demoBtn.textContent='Playing on '+(d.target||'your desktop')+'\u2026';+ document.getElementById('tstepn').textContent=(d.idx+1)+'/'+d.total;+ document.getElementById('tsteptitle').textContent=d.title||'';+ paused=!!d.paused;+ document.getElementById('tplay').innerHTML=paused?'\u25B6':'\u23F8';+ document.getElementById('tstate').textContent=paused?'(paused)':'';+ document.getElementById('tfill').style.width=Math.round(100*(d.idx+1)/(d.total||1))+'%';+ }catch(e){}+ }+ demoBtn.onclick=async function(){+ demoBtn.disabled=true; demoBtn.textContent='Starting\u2026';+ try{ await fetch('api/demo/start',{method:'POST'}); }catch(e){}+ if(poll) clearInterval(poll); poll=setInterval(tick,700); tick();+ };+ fetch('api/demo/state',{cache:'no-store'}).then(r=>r.json()).then(d=>{ if(d.running){ if(poll)clearInterval(poll); poll=setInterval(tick,700); tick(); } }).catch(function(){});+})(); load(); setInterval(load,15000); </script>`; @@ -349,124 +373,129 @@ async function demoTarget() { return { name: idles[0].name, all: t.all, at: Date.now(), idles }; } -async function runDemo() {- // v2.0.319 demo v4, all from John watching v3: (1) voice is adom-tts (neural, Adom- // pronunciations), never the SAPI robot; (2) no caption-over-caption: the raise step lets the- // foreground caption BE the narration; (3) a dedicated act drives the taskbar progress bar 0- // to 100 and says it runs for the length of ANY verb or long process; (4) the three toolbar- // extension tiles get introduced; (5) the Adom cursor is turned OFF at the end.- const lines = [];+// ── Controllable demo (John: "a play/pause/next/prev/exit ... easy way to stop", "pre-create the+// mp3 audio files so you don't generate them on the fly"). The demo is a list of STEPS; a stateful+// runner plays them and honors pause / next / prev / stop between and inside steps. Narration audio+// is pre-rendered to a cache at serve time so playback is instant. State + controls are exposed at+// /api/demo/{start,control,state}; the dashboard shows a transport bar while it runs.+const DEMO_AUDIO_DIR = path.join(os.tmpdir(), 'pup-demo-audio');+let demo = null; // { running, paused, idx, total, title, target, abort, jump, log, startedAt }++function demoSteps(sid) {+ // Each: { title, say, run(ctx) }. ctx: { ab, sid, tgt, set(k,v), get(k) }. Narration is the `say`+ // string (spoken + captioned by the runner before run()); keep run() to the ACTION.+ return [+ { title: 'Intro', say: 'Hi, this is the pup demo: an A I browser living on your desktop. I will narrate everything I do. You can pause, skip, or stop me any time from the controls.', run: async () => {} },+ { title: 'Open in background', say: 'Opening a window in the BACKGROUND: nothing pops over your work. Look at your taskbar for a new teal button wearing my thread name, with the page icon badged on it.',+ run: async (c) => { const o = await c.ab('pup_open_window', { sessionId: sid, url: 'https://wiki.adom.inc/adom/pup-bridge', owner: 'pup dock demo' }, 120000); if (o._err) throw new Error('open failed: ' + o._err); } },+ { title: 'Progress bar', say: 'Watch that button: I am driving its PROGRESS BAR by hand. pup runs one of these for the length of ANY verb or long operation, sized to how long it usually takes.',+ run: async (c) => { let hwnd = null; for (let i = 0; i < 4 && !hwnd; i++) { const wi = await c.ab('pup_window_info', { sessionId: sid }, 20000); hwnd = wi && wi.hwnd; if (!hwnd) await c.sleep(1200); } c.set('hwnd', hwnd); if (!hwnd) return; for (const v of [15, 40, 65, 85, 100]) { if (c.stopping()) break; await c.ab('desktop_taskbar', { hwnd, progress: { state: 'normal', value: v } }, 8000); await c.sleep(500); } await c.ab('desktop_taskbar', { hwnd, progress: { state: 'none' } }, 8000); } },+ { title: 'Foreground (captioned)', say: 'Bringing the window up so you can watch. pup always captions a foreground first: who, and why. Here it comes.',+ run: async (c) => { await c.ab('pup_raise_os_window', { sessionId: sid, foregroundReason: 'the pup demo you started: bringing the window up so you can watch me drive it' }, 30000); await c.sleep(900); } },+ { title: 'The cursor', say: 'That teal cursor is me: it glides to where I am about to act and says why.',+ run: async (c) => { await c.ab('pup_hover', { sessionId: sid, x: 700, y: 300, intent: 'this is my cursor', steps: 16 }, 20000); } },+ { title: 'Toolbar tiles', say: 'Top right, next to the address bar: three teal tiles. The pup dots are my menu. The pencil starts annotation. The avatar is your adom you identity: this window is signed in as YOU.',+ run: async (c) => { await c.ab('pup_hover', { sessionId: sid, x: 1180, y: 8, intent: 'look UP at the toolbar', steps: 14 }, 20000); } },+ { title: 'Screenshot', say: 'Screenshot now: the same progress bar runs on its own while I capture, even with the window buried in the background.',+ run: async (c) => { const sh = await c.ab('pup_screenshot', { sessionId: sid }, 60000); c.log('screenshot: ' + (sh._err ? sh._err : (sh.width + 'x' + sh.height))); } },+ { title: 'Annotate (feedback)', say: 'Now why annotation exists: FEEDBACK to your A I. Circle the thing you mean, and the capture goes to the A I thread that owns this window, and onto your clipboard for any A I chat. Watch.',+ run: async (c) => { const arm = await c.ab('pup_annotate', { sessionId: sid, tool: 'pen' }, 30000); if (arm._err) { c.log('annotate did not arm: ' + arm._err); return; } const draw = "(async()=>{const r=document.getElementById('__pup_annotate');if(!r)return 'no overlay';const cv=r.querySelector('canvas');if(!cv)return 'no canvas';const mk=(t,x,y)=>new PointerEvent(t,{bubbles:true,cancelable:true,clientX:x,clientY:y,pointerId:1,isPrimary:true,button:0,buttons:t==='pointerup'?0:1});const cx=Math.round(innerWidth*0.35),cy=Math.round(innerHeight*0.4),rad=Math.min(150,Math.round(innerHeight*0.2));cv.dispatchEvent(mk('pointerdown',cx+rad,cy));for(let a=0.12;a<=6.55;a+=0.12){cv.dispatchEvent(mk('pointermove',cx+rad*Math.cos(a),cy+rad*Math.sin(a)));await new Promise(q=>setTimeout(q,55));}cv.dispatchEvent(mk('pointerup',cx+rad,cy));return 'drew';})()"; const dr = await c.ab('pup_evaluate', { sessionId: sid, expression: draw }, 40000); c.log('draw: ' + (dr._err || dr.result || '?')); } },+ { title: 'Capture landed', say: 'Pen up: captured. It is on your clipboard with a strip naming this thread and page, and the owning A I thread sees it. Your A I now knows exactly what you meant.', run: async () => {} },+ { title: 'Jump list', say: 'Every pup button has a right-click menu. Opening it for you now, watch the bottom of your screen.',+ run: async (c) => { const uia = "Add-Type -AssemblyName UIAutomationClient; $tb=[System.Windows.Automation.AutomationElement]::RootElement.FindFirst('Children',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_TrayWnd'))); $btn=$tb.FindAll('Descendants',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))) | Where-Object { $_.Current.Name -like '*pup dock demo*' } | Select-Object -First 1; if ($btn) { $r=$btn.Current.BoundingRectangle; [int]($r.X+$r.Width/2); [int]($r.Y+$r.Height/2) }"; const rc = await c.ab('desktop_run_script', { language: 'powershell', body: uia, timeoutSeconds: 30 }, 35000); const nums = String(rc.stdout || '').trim().split(/\r?\n/).map(Number).filter(Number.isFinite); if (nums.length >= 2) { await c.ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000); await c.sleep(1100); await c.ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000); await c.sleep(2200); await c.ab('desktop_press_key', { key: 'Escape' }, 10000); c.log('opened jump list'); } else c.log('jump-list rect not found'); } },+ { title: 'Cleanup + close', say: 'And when I am done, I clean up: cursor off, and closing the window now. Watch the button leave your taskbar. That is pup. Say, show me in pup, to any Adom A I.',+ run: async (c) => { await c.ab('pup_evaluate', { sessionId: sid, expression: "['__adom_cursor__','__pupCursor','__adom_cursor_ring__','__adom_cursor_label__'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});1" }, 15000).catch(() => {}); await c.ab('pup_close_window', { sessionId: sid }, 30000); c.log('closed the demo window'); } },+ ];+}++function audioFileFor(text) {+ let h = 0; for (let i = 0; i < text.length; i++) { h = (h * 31 + text.charCodeAt(i)) | 0; }+ return path.join(DEMO_AUDIO_DIR, 'a' + (h >>> 0).toString(16) + '.mp3');+}+function renderLine(text) {+ const f = audioFileFor(text);+ if (fs.existsSync(f)) return Promise.resolve(f);+ return new Promise((resolve) => {+ execFile('adom-tts', ['say', text, '--out', f, '--thread', 'pup demo', '--desc', 'pup demo narration'], { env: ENV, timeout: 45000 }, (err) => resolve(err ? null : f));+ });+}+async function preRenderDemoAudio() { // warm the cache at serve time so playback never generates on the fly+ try { fs.mkdirSync(DEMO_AUDIO_DIR, { recursive: true }); } catch (e) {}+ for (const st of demoSteps('pup-dock-demo')) { await renderLine(st.say); }+}+let _demoAudioChild = null;+function stopDemoAudio() { const c = _demoAudioChild; _demoAudioChild = null; if (c) { try { c.kill(); } catch (e) {} } }+function playLine(text) { // play the (already-rendered) mp3 on the shared player; returns when it finishes+ return new Promise((resolve) => {+ const c = execFile('adom-tts', ['say', text, '--out', audioFileFor(text), '--thread', 'pup demo', '--desc', 'pup demo narration', '--play'], { env: ENV, timeout: 40000 }, () => { if (_demoAudioChild === c) _demoAudioChild = null; resolve(); });+ _demoAudioChild = c;+ });+}++async function runDemoControllable() { const sid = 'pup-dock-demo'; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const tgt = await demoTarget();- lines.push('Playing on desktop: ' + tgt.name + (tgt.idles ? ' (you are at it: idle ' + Math.round(tgt.idles[0].idle / 1000) + 's; ' + tgt.idles.slice(1).map(i => i.name + ' idle ' + Math.round(i.idle / 1000) + 's').join(', ') + ')' : ''));-- // Voice: adom-tts (edge neural voice + Adom pronunciation overrides + cache) through the shared- // player; SAPI only if adom-tts is missing; captions always carry the words regardless.- let voice = 'adom-tts';- const speak = async (text) => {- if (voice === 'off') return;- if (voice === 'adom-tts') {- const r = await new Promise((resolve) => {- execFile('adom-tts', ['say', text, '--out', path.join(os.tmpdir(), 'pup-demo-line.mp3'), '--thread', 'pup demo', '--desc', 'pup demo narration: ' + text.slice(0, 60), '--play'],- { env: ENV, timeout: 45000 }, (err) => resolve(!err));- });- if (r) { await sleep(Math.min(6000, Math.max(1000, text.split(/\s+/).length * 240))); return; } // let the clip play out, briskly- voice = 'sapi'; lines.push(' (adom-tts unavailable; falling back to the system voice)');- }- const safe = text.replace(/[`$"\\]/g, ' ').replace(/'/g, "''");- const r2 = await ab('desktop_run_script', { language: 'powershell', body: "Add-Type -AssemblyName System.Speech; $v = New-Object System.Speech.Synthesis.SpeechSynthesizer; $v.Rate = 1; $v.Speak('" + safe + "')", timeoutSeconds: 30 }, 35000, tgt.name);- if (r2._err) { voice = 'off'; lines.push(' (no voice on this machine; captions carry the demo)'); }+ const steps = demoSteps(sid);+ demo = { running: true, paused: false, idx: 0, total: steps.length, title: steps[0].title, target: tgt.name, abort: false, jump: null, log: [], startedAt: Date.now() };+ await preRenderDemoAudio(); // fast if already warm+ const store = {};+ const capId = 'pup-demo';+ const ctx = {+ sid, tgt, sleep,+ ab: (verb, a, t) => ab(verb, a, t, tgt.name),+ set: (k, v) => { store[k] = v; }, get: (k) => store[k],+ log: (m) => demo.log.push(m),+ stopping: () => demo.abort || demo.jump != null, };- const cap = (text) => {- const ms = Math.min(12000, Math.max(2500, 900 + text.trim().split(/\s+/).length * 350));- lines.push(' [caption] ' + text);- return ab('desktop_caption', { text, duration: ms, id: 'pup-demo', position: 'bottom', size: 'medium' }, 8000, tgt.name).then(() => ms);- };- const clearCap = () => ab('desktop_caption', { text: ' ', duration: 300, id: 'pup-demo', position: 'bottom', size: 'medium' }, 8000, tgt.name).catch(() => {});- const say = async (text, extra) => { await Promise.all([cap(text), speak(text)]); await sleep(extra || 150); };-- await say('Hi, this is the pup demo: an A I browser living on your desktop. I will narrate everything I do.');- await say('Opening a window in the BACKGROUND: nothing pops over your work. Look at your taskbar: a new teal button wearing my thread name, with the page icon badged on it.', 100);- const open = await ab('pup_open_window', { sessionId: sid, url: 'https://wiki.adom.inc/adom/pup-bridge', owner: 'pup dock demo' }, 120000, tgt.name);- if (open._err) { await say('I could not open a window: ' + open._err); return lines; }- lines.push(' open: ' + (open.sessionId || sid));-- // THE PROGRESS BAR, driven visibly (John: "show how you drive the progress bar... explain you do- // that for the length of any verb or long running process").- let hwnd = null;- for (let i = 0; i < 4 && !hwnd; i++) { const wi = await ab('pup_window_info', { sessionId: sid }, 20000, tgt.name); hwnd = wi && wi.hwnd; if (!hwnd) await sleep(1500); }- if (hwnd) {- await say('Watch that button: I am driving its PROGRESS BAR by hand. pup runs one of these for the length of ANY verb or long operation, sized to how long it usually takes.', 0);- for (const v of [15, 40, 65, 85, 100]) { await ab('desktop_taskbar', { hwnd, progress: { state: 'normal', value: v } }, 8000, tgt.name); await sleep(550); }- await sleep(400);- await ab('desktop_taskbar', { hwnd, progress: { state: 'none' } }, 8000, tgt.name);- await say('Fills, completes, clears: that is how you always know when I am working on a window. No orange flashing.');- } else lines.push(' (no hwnd resolved yet; skipped the manual progress sweep)');-- // The raise: ONE caption on screen at a time, ever. My caption channel goes quiet, the voice- // narrates, the raise paints ITS caption, and I wait out that caption's word-scaled duration- // (plus margin) before my channel speaks with a caption again. (v2.0.320: John saw my next- // caption land on top of the still-visible foreground caption.)- await clearCap();- const raiseWhy = 'the pup demo you started: bringing the window up so you can watch me drive it';- await speak('Bringing the window up so you can watch. pup always captions a foreground first: who, and why. Here it comes.');- lines.push(' [caption via raise] ' + raiseWhy);- const fgCapMs = Math.min(12000, Math.max(2500, 900 + ('AI thread pup dock demo brought ' + sid + ' to the front. Reason: ' + raiseWhy).split(/\s+/).length * 350));- await ab('pup_raise_os_window', { sessionId: sid, foregroundReason: raiseWhy }, 30000, tgt.name);- const fgShownAt = Date.now();- await ab('pup_hover', { sessionId: sid, x: 700, y: 300, intent: 'this is my cursor: it glides and narrates my intent', steps: 16 }, 20000, tgt.name);- await speak('That teal cursor is me: it glides to where I am about to act and says why.');- const fgLeft = fgCapMs + 700 - (Date.now() - fgShownAt);- if (fgLeft > 0) await sleep(fgLeft); // never two captions: wait out the foreground caption+ // abort/jump-aware sleep so controls feel instant+ ctx.sleep = async (ms) => { const end = Date.now() + ms; while (Date.now() < end) { if (demo.abort || demo.jump != null) return; while (demo.paused && !demo.abort) await sleep(120); await sleep(Math.min(120, Math.max(1, end - Date.now()))); } }; - // The three toolbar tiles (John: "you should have shown the 3 browser extensions").- await ab('pup_hover', { sessionId: sid, x: 1180, y: 8, intent: 'look UP at the toolbar, right of the address bar', steps: 14 }, 20000, tgt.name).catch(() => {});- await say('Top right, next to the address bar: three teal tiles. The pup dots are my menu. The pencil starts annotation. The avatar is your adom you identity: this window is signed in as YOU.', 150);-- await say('Screenshot now: the same progress bar runs on its own while I capture, even with the window buried in the background.');- const shot = await ab('pup_screenshot', { sessionId: sid }, 60000, tgt.name);- lines.push(shot._err ? ' screenshot failed: ' + shot._err : ' screenshot: ' + shot.width + 'x' + shot.height);-- await say('Now why annotation exists: FEEDBACK to your A I. Circle the thing you mean, and the capture goes to the A I thread that owns this window, and onto your clipboard for any A I chat. Watch.');- const arm = await ab('pup_annotate', { sessionId: sid, tool: 'pen' }, 30000, tgt.name);- if (!arm._err) {- const draw = "(async()=>{const r=document.getElementById('__pup_annotate');if(!r)return 'no overlay';const cv=r.querySelector('canvas');if(!cv)return 'no canvas';const mk=(t,x,y)=>new PointerEvent(t,{bubbles:true,cancelable:true,clientX:x,clientY:y,pointerId:1,isPrimary:true,button:0,buttons:t==='pointerup'?0:1});const cx=Math.round(innerWidth*0.35),cy=Math.round(innerHeight*0.4),rad=Math.min(150,Math.round(innerHeight*0.2));cv.dispatchEvent(mk('pointerdown',cx+rad,cy));for(let a=0.12;a<=6.55;a+=0.12){cv.dispatchEvent(mk('pointermove',cx+rad*Math.cos(a),cy+rad*Math.sin(a)));await new Promise(q=>setTimeout(q,55));}cv.dispatchEvent(mk('pointerup',cx+rad,cy));return 'drew';})()";- const drew = await ab('pup_evaluate', { sessionId: sid, expression: draw }, 40000, tgt.name);- const drewOk = !drew._err && String(drew.result || '') === 'drew';- if (drewOk) {- lines.push(' drew the red circle live');- await say('Pen up: captured. It is on your clipboard with a strip naming this thread and page, and the owning A I thread sees it. Your A I now knows exactly what you meant.');- } else { lines.push(' draw did not run: ' + (drew._err || drew.result || 'unknown')); await say('The drawing step did not run on this page, so I will move on.'); }- } else { lines.push(' annotate did not arm: ' + arm._err); await say('The annotation overlay did not arm on this page, so I will move on.'); }-- // Auto-open the right-click menu for real (John: "why are you not auto-opening those menus"):- // find the button's rectangle via UIAutomation, right-click it twice (the shell's known first-- // click blip), let the menu sit on screen, then dismiss it.- await say('Every pup button has a right-click menu. Opening it for you now, watch the bottom of your screen.', 0);- const uia = "Add-Type -AssemblyName UIAutomationClient; $tb=[System.Windows.Automation.AutomationElement]::RootElement.FindFirst('Children',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_TrayWnd'))); $btn=$tb.FindAll('Descendants',(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))) | Where-Object { $_.Current.Name -like '*pup dock demo*' } | Select-Object -First 1; if ($btn) { $r=$btn.Current.BoundingRectangle; [int]($r.X+$r.Width/2); [int]($r.Y+$r.Height/2) }";- const rc = await ab('desktop_run_script', { language: 'powershell', body: uia, timeoutSeconds: 30 }, 35000, tgt.name);- const nums = String(rc.stdout || '').trim().split(/\r?\n/).map(Number).filter(Number.isFinite);- if (nums.length >= 2) {- await ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000, tgt.name); await sleep(1200);- await ab('desktop_right_click', { x: nums[0], y: nums[1] }, 15000, tgt.name); await sleep(600);- await say('There: annotate, switch wiki views, my dashboard, move to workspace, close all. One right-click away.', 400);- await ab('desktop_press_key', { key: 'Escape' }, 10000, tgt.name).catch(() => {});- lines.push(' opened the jump list at ' + nums[0] + ',' + nums[1]);- } else {- lines.push(' (jump-list button rect not found; narrated instead)');- await say('It holds: annotate this page, switch wiki views, open my dashboard, and close every pup window at once.');+ const stepBanner = () => { const s = steps[demo.idx]; return (demo.idx + 1) + '/' + steps.length + ': ' + s.title; };+ while (demo.idx >= 0 && demo.idx < steps.length && !demo.abort) {+ while (demo.paused && !demo.abort) { await sleep(150); }+ if (demo.abort) break;+ const step = steps[demo.idx];+ demo.title = step.title; demo.jump = null;+ // narrate: caption + spoken audio together (the pre-rendered mp3). The audio is RACED against an+ // abort/jump watcher so Stop and Next feel instant: if either fires, kill the clip and move on.+ ab('desktop_caption', { text: step.say, duration: Math.min(12000, Math.max(2500, 900 + step.say.split(/\s+/).length * 350)), id: capId, position: 'bottom', size: 'medium' }, 8000, tgt.name).catch(() => {});+ await Promise.race([+ playLine(step.say),+ (async () => { while (!demo.abort && demo.jump == null) await sleep(120); stopDemoAudio(); })(),+ ]);+ if (!demo.abort && demo.jump == null) { try { await step.run(ctx); } catch (e) { demo.log.push('step ' + stepBanner() + ' failed: ' + e.message); } }+ if (demo.abort) break;+ if (demo.jump != null) { demo.idx = Math.max(0, Math.min(steps.length - 1, demo.jump)); demo.jump = null; }+ else demo.idx++; }- // cursor OFF before leaving (John: "you never turned the cursor off")- await ab('pup_evaluate', { sessionId: sid, expression: "['__adom_cursor__','__pupCursor','__adom_cursor_ring__','__adom_cursor_label__'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});'cursor off'" }, 15000, tgt.name).catch(() => {});- await say('And when I am done, I clean up after myself: closing the window now. Watch the button leave your taskbar.');- await ab('pup_close_window', { sessionId: sid }, 30000, tgt.name);- lines.push(' closed the demo window');- await cap('pup demo done: window closed, taskbar clean. Say, show me in pup, to any Adom AI.');- await speak('That is pup. Say, show me in pup, to any Adom A I.');- return lines;+ const aborted = demo.abort;+ // always clean up the demo window and any lit progress bar+ try { await ab('pup_close_window', { sessionId: sid }, 20000, tgt.name); } catch (e) {}+ try { await ab('desktop_caption', { text: aborted ? 'pup demo stopped.' : 'pup demo done. Say, show me in pup, to any Adom AI.', duration: 3000, id: capId, position: 'bottom', size: 'medium' }, 8000, tgt.name); } catch (e) {}+ demo.running = false;+ return { done: true, aborted, log: demo.log };+}++function demoState() {+ if (!demo) return { running: false };+ return { running: demo.running, paused: demo.paused, idx: demo.idx, total: demo.total, title: demo.title, target: demo.target, startedAt: demo.startedAt, log: demo.log.slice(-8) };+}+function demoControl(action) {+ if (!demo || !demo.running) return { ok: false, error: 'no demo is running' };+ if (action === 'pause') demo.paused = true;+ else if (action === 'resume') demo.paused = false;+ else if (action === 'next') { demo.jump = Math.min(demo.total - 1, demo.idx + 1); demo.paused = false; }+ else if (action === 'prev') { demo.jump = Math.max(0, demo.idx - 1); demo.paused = false; }+ else if (action === 'stop') { demo.abort = true; demo.paused = false; try { stopDemoAudio(); } catch (e) {} }+ if (action === 'next' || action === 'prev') { try { stopDemoAudio(); } catch (e) {} }+ else return { ok: false, error: 'unknown action' };+ return { ok: true, ...demoState() }; } +// legacy synchronous entry (kept so the old button/callers still work): start + wait.+async function runDemo() { const r = await runDemoControllable(); return (demo && demo.log ? demo.log : []).concat(r.aborted ? ['(stopped)'] : ['(done)']); }+ // ── serve ─────────────────────────────────────────────────────────────────────────────────────── function alive(port) { return new Promise((resolve) => {@@ -532,7 +561,20 @@ async function serve() { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS }); return res.end(JSON.stringify(body)); }- if (u.endsWith('/api/demo') && req.method === 'POST') {+ if (u.endsWith('/api/demo/start') && req.method === 'POST') {+ if (demo && demo.running) { res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ started: false, alreadyRunning: true, ...demoState() })); }+ runDemoControllable().catch((e) => { if (demo) demo.log.push('runner crashed: ' + e.message); if (demo) demo.running = false; });+ res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ started: true, total: (demo && demo.total) || 0 }));+ }+ if (u.endsWith('/api/demo/control') && req.method === 'POST') {+ let body = ''; req.on('data', d => { body += d; });+ req.on('end', () => { let action = ''; try { action = String(JSON.parse(body).action || ''); } catch (e) {} const r = demoControl(action); res.writeHead(200, { 'content-type': 'application/json', ...CORS }); res.end(JSON.stringify(r)); });+ return;+ }+ if (u.endsWith('/api/demo/state')) {+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS }); return res.end(JSON.stringify(demoState()));+ }+ if (u.endsWith('/api/demo') && req.method === 'POST') { // legacy synchronous run (kept) let lines; try { lines = await runDemo(); } catch (e) { lines = ['demo failed: ' + e.message]; } res.writeHead(200, { 'content-type': 'application/json', ...CORS }); return res.end(JSON.stringify({ lines }));@@ -557,6 +599,9 @@ async function serve() { server.listen(0, '127.0.0.1', () => { const port = server.address().port; try { fs.writeFileSync(STATE, JSON.stringify({ port, pid: process.pid, at: Date.now(), version: TOOL_VERSION, dockWebview: DOCK_WEBVIEW || null, dockApp: DOCK_APP || null })); } catch (e) {}+ // v2.0.322: pre-render the demo narration to mp3 now, so a later "Run the demo" plays instantly+ // (John: "pre-create these mp3 audio files so you don't generate them on the fly").+ preRenderDemoAudio().then(() => console.error('pup-dashboard: demo audio pre-rendered')).catch(() => {}); }); }