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
v1.9.85: three-way merge of mac-parity + Windows 1.9.76-84 lines; restore pup-cat icons to the repo
12 files changed
+205−18
CHANGELOG.md+11−1adom-bridge-puppeteer-manifest.json+5−5src/BRIDGE_VERSION+1−1src/bridge.json+1−1src/chrome.js+23src/icons/pup-cat-app9.icosrc/icons/pup-cat-default9.icosrc/icons/pup-cat-mixed9.icosrc/icons/pup-cat-web9.icosrc/icons/pup-cat-wiki-in9.icosrc/icons/pup-cat-wiki9.icosrc/server.js+164−10CHANGELOG.md+11−1@@ -1,6 +1,16 @@ # Changelog — Adom Desktop Puppeteer Bridge -## 1.9.75 — 2026-07-24 — Verified-AD-base resolver (HD-hosting fix) + seed-complete zip+## 1.9.85 — 2026-07-24 — MERGE: mac-parity line + John's Windows 1.9.76–1.9.84 line++The two release lines had silently diverged (mac 1.9.74–75 vs Windows 1.9.76–84, neither+containing the other; caught when a 1.9.75 publish rolled the fleet manifest back over+1.9.84). 1.9.85 is the three-way merge (base 1.9.73): all of John's 1.9.76–84 server/chrome+changes + full mac parity + the items below. Also restores the pup-cat-*9.ico category icons+to BOTH the git repo and the zip (they previously lived only in release zips, so mac-line+zips built from the repo silently dropped them).++### carried from the unreleased 1.9.75+ 1. VERIFIED AD BASE. KICAD's env-injection finding (confirmed from hd-control source): an HD-spawned bridge gets ADOM_DIRECT_API_URL pointed at HD's control API (:47084),
adom-bridge-puppeteer-manifest.json+5−5@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "1.9.84",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.84/adom-bridge-puppeteer-v1.9.84.zip",- "sha256": "249db0f09fef503fddc550f7829d8e9850a4ed06c223911e847bd656ab041b68",- "size": 317501,+ "version": "1.9.85",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.85/adom-bridge-puppeteer-v1.9.85.zip",+ "sha256": "1578c060e7c638662caa9f330ac23d5aee34ecccf766da56d915b63793533d4f",+ "size": 358990, "verbPrefixes": [ "browser_", "desktop_recorder_",@@ -12,5 +12,5 @@ ], "healthEndpoint": "/status", "statusVerb": "browser_readiness",- "released_at": "2026-07-24T11:54:27Z"+ "released_at": "2026-07-24T11:59:05Z" }
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.75+1.9.85
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "1.9.75",+ "version": "1.9.85", "description": "Drives a browser ALREADY on the machine — installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) — launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome → Edge → CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, …). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only — every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs — a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics — background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
src/chrome.js+23@@ -335,6 +335,29 @@ function installChrome({ onProgress } = {}) { } state.buildId = buildId;+ // v1.9.75 — SELF-HEAL A HALF-POPULATED CACHE DIR (wiki issue #15, AdityaAngajala).+ // @puppeteer/browsers REFUSES to install when the versioned folder already exists but the+ // executable inside it does not:+ // "The browser folder ...\\chrome\\win64-146.0.7680.76 exists but the executable+ // ...\\chrome-win64\\chrome.exe is missing"+ // Easy state to reach: an interrupted download, a bridge killed mid-unzip, or npm's+ // allow-scripts gate skipping puppeteer's postinstall partway. Once in it, EVERY later+ // install attempt throws the same error forever and pup has no browser of its own — the+ // user's only escape is knowing to pin a system browser. detectChrome() already reports+ // installed:false here (it validates the real exe), so the dir is known junk. Delete it+ // and let the install proceed instead of failing in a loop.+ try {+ const { computeExecutablePath } = require('@puppeteer/browsers');+ const expectedExe = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir: cacheDir() });+ if (expectedExe && !fileExists(expectedExe)) {+ let dir = path.dirname(expectedExe);+ const versioned = path.basename(dir).includes(buildId) ? dir : path.dirname(dir);+ if (versioned && versioned.includes(buildId) && fs.existsSync(versioned)) {+ console.log(`[chrome] cache dir for ${buildId} exists but its executable is missing — removing the half-populated folder and re-downloading: ${versioned}`);+ fs.rmSync(versioned, { recursive: true, force: true });+ }+ }+ } catch (e) { console.log(`[chrome] half-populated-cache check skipped: ${e.message}`); } console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} (disk free: ${freeMb == null ? 'unknown' : freeMb + ' MB'}) ...`); const installed = await install({
src/icons/pup-cat-app9.icoadded⋯ 1 unchanged line ⋯
src/icons/pup-cat-default9.icoadded⋯ 1 unchanged line ⋯
src/icons/pup-cat-mixed9.icoadded⋯ 1 unchanged line ⋯
src/icons/pup-cat-web9.icoadded⋯ 1 unchanged line ⋯
src/icons/pup-cat-wiki-in9.icoadded⋯ 1 unchanged line ⋯
src/icons/pup-cat-wiki9.icoadded⋯ 1 unchanged line ⋯
src/server.js+164−10@@ -51,6 +51,50 @@ function runPsHiddenPromise(scriptPath, { timeout = 8000 } = {}) { }); }); }+// ── macOS window-management primitives (the darwin analogs of the Win32 helpers) ──+// Strategy on macOS: no cross-process per-window z-bottom API exists, and AppKit CLAMPS+// windows onto a visible screen (the -32000 off-screen launch trick doesn't hold). The+// reliable "background by default" primitive is the APP-LEVEL HIDE (the Cmd+H state):+// every pup session runs its own browser process (one per profile), so hiding that+// process hides exactly that session's windows — while CDP keeps driving + rendering+// them (--disable-backgrounding-occluded-windows), so screenshot/eval still work.+// Raise = unhide + activate. Driven via System Events (rides the Automation grant the+// embedding Adom Desktop already holds; a denied grant degrades to applied:false, never+// a throw). Hide is PER-PROCESS, so multi-session shared-profile windows hide/show as a+// group on mac — a documented DEGRADED vs the per-hwnd Windows path.+function runOsascript(script, { timeout = 6000 } = {}) {+ try { return execFileSync('/usr/bin/osascript', ['-e', script], { encoding: 'utf8', timeout }).trim(); }+ catch (e) { return null; }+}+function runOsascriptPromise(script, { timeout = 8000 } = {}) {+ return new Promise(resolve => {+ execFile('/usr/bin/osascript', ['-e', script], { encoding: 'utf8', timeout }, (err, stdout) => resolve(err ? null : (stdout || '').trim()));+ });+}+// Hide (true) / unhide (false) every window of a process. Resolves true if the script ran.+async function macSetAppHiddenByPid(pid, hidden) {+ if (!pid) return false;+ const r = await runOsascriptPromise(`tell application "System Events" to set visible of (first process whose unix id is ${Number(pid)}) to ${hidden ? 'false' : 'true'}`);+ return r !== null;+}+// Unhide + bring the process's windows to the foreground (the user-asked-to-watch path).+async function macActivateByPid(pid) {+ if (!pid) return false;+ await runOsascriptPromise(`tell application "System Events" to set visible of (first process whose unix id is ${Number(pid)}) to true`);+ const r = await runOsascriptPromise(`tell application "System Events" to set frontmost of (first process whose unix id is ${Number(pid)}) to true`);+ return r !== null;+}+// The pid that owns a session's browser (detached launches record it; puppeteer.launch+// exposes process()). Null when unresolvable — callers report applied:false.+function macSessionPid(session) {+ try {+ const be = browsers.get(session.profileName);+ const b = be && be.browser;+ if (!b) return null;+ return b._detachedPid || (b.process() && b.process().pid) || null;+ } catch (e) { return null; }+}+ const puppeteer = require('puppeteer'); const credentialVault = require('./credential_vault'); const chrome = require('./chrome'); // Chrome-for-Testing self-heal: detect / auto-install / readiness (cold-start fix)@@ -874,7 +918,8 @@ async function findChromesByProfile(profileName) { const result = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf8', timeout: 5_000 }); if (result.stdout) { for (const line of result.stdout.split('\n')) {- if (!line.includes('chrome')) continue;+ // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).+ if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue; if (/--type=/.test(line)) continue; const portMatch = line.match(/--remote-debugging-port=(\d+)/); if (!portMatch) continue;@@ -1158,7 +1203,8 @@ async function rescanAllProfiles({ adoptOrphans = false } = {}) { if (result.stdout) { const seen = new Set(); for (const line of result.stdout.split('\n')) {- if (!line.includes('chrome')) continue;+ // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).+ if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue; if (line.includes('--type=')) continue; if (!line.includes('remote-debugging-port')) continue; if (!line.includes(PROFILES_DIR)) continue;@@ -2807,6 +2853,13 @@ async function osRaiseSessionWindow(session, sessionId) { try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {} try { await session.page.bringToFront(); } catch (e) {} session._foreground = true;+ if (process.platform === 'darwin') {+ // macOS: unhide + activate the session's browser process (the CDP part above sized it).+ const pid = macSessionPid(session);+ const ok = await macActivateByPid(pid);+ console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${ok}`);+ return;+ } if (process.platform !== 'win32') return; // v1.9.66 — RAISE BY HWND. The CDP-only path above (setWindowBounds + bringToFront) happens to // surface a CHROME window, but it does NOT raise an EDGE one: bringToFront activates the TAB@@ -3775,6 +3828,43 @@ setInterval(() => { }, 30000); async function parkSessionWindowDirect(page, sessionId) {+ // ── macOS park ── the whole background contract is one primitive: (re-)hide the+ // session's browser process. The window was born hidden (pre-window hide in+ // tryDetachedLaunch); here we size it full-screen via CDP while STILL hidden — so a+ // later raise shows a properly-sized window — then re-assert the hide and verify.+ // No z-order machinery: a hidden app has no on-screen windows to cover the user.+ if (process.platform === 'darwin') {+ try {+ // The session may not be registered in `sessions` yet at launch-park time — resolve+ // the pid from the page's OWN browser (detached pid, else puppeteer's child process).+ let pid = null;+ try { const b = page.browser(); pid = b._detachedPid || (b.process() && b.process().pid) || null; } catch (e) {}+ if (!pid) { const session = sessions.get(sessionId); pid = session ? macSessionPid(session) : null; }+ try {+ const cdp2 = await page.target().createCDPSession();+ const { windowId } = await cdp2.send('Browser.getWindowForTarget');+ let s = null;+ for (let i = 0; i < 4 && !s; i++) {+ s = await page.evaluate(() => ({ w: screen.availWidth, h: screen.availHeight })).catch(() => null);+ if (s && !(s.w > 400 && s.h > 300)) s = null;+ if (!s) await new Promise(r2 => setTimeout(r2, 300));+ }+ await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+ if (s) await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: Math.round(s.w), height: Math.round(s.h) } });+ try { await cdp2.detach(); } catch (e) {}+ } catch (e) {}+ if (!pid) { console.log(`[park] "${sessionId}": no pid to hide (mac) — park-failed`); return 'park-failed'; }+ const hidden = await macSetAppHiddenByPid(pid, true);+ if (!hidden) {+ console.log(`[park] "${sessionId}": mac hide failed (System Events denied? grant Automation to Adom Desktop) — park-failed`);+ return 'park-failed';+ }+ return 'backgrounded';+ } catch (e) {+ console.error(`[park] ${sessionId} (mac): ${e.message}`);+ return 'error';+ }+ } if (process.platform !== 'win32') return null; let cdp = null; try {@@ -4507,6 +4597,16 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer b._bgResult = 'parking'; b._needsPark = true; }+ if (!foreground && process.platform === 'darwin') {+ // macOS background-by-default: hide the app NOW, while --no-startup-window means it+ // has no window yet — windows created by a hidden app are born hidden, so the launch+ // is truly invisible (AppKit clamps the -32000 off-screen coords, so only the hide+ // covers the gap here). The park then sizes the window + re-asserts the hide.+ b._bgResult = 'parking';+ b._needsPark = true;+ b._macHidden = await macSetAppHiddenByPid(child.pid, true);+ if (!b._macHidden) console.log(`[launch] mac pre-window hide failed for pid ${child.pid} (Automation grant missing?) — park will retry`);+ } return b; }; @@ -4514,7 +4614,9 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer // attempts each (with orphan/lock cleanup between attempts). If a browser can't // actually spawn (corrupt install, wrong arch, locked), fall THROUGH to the next // candidate rather than failing the open. First one that launches wins + is cached.- const isWinDetached = process.platform === 'win32' && userDataDir;+ // v1.9.74: the detached launch runs on macOS too (same --no-startup-window contract;+ // the mac park is the app-level hide instead of the off-screen/z-bottom dance).+ const isDetachedLaunch = (process.platform === 'win32' || process.platform === 'darwin') && userDataDir; const MAX_ATTEMPTS = 3; let lastErr = null; let chosen = null;@@ -4529,7 +4631,7 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer lastErr = null; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try {- if (isWinDetached) {+ if (isDetachedLaunch) { browser = await tryDetachedLaunch(attempt, cand.executablePath); } else { browser = await puppeteer.launch(launchOpts);@@ -4563,7 +4665,7 @@ async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPer // v1.8.71 (#202): a BRANDED-browser launch means CfT wasn't cached — kick a background // CfT install (once per bridge process) so this box converges to the deterministic // CfT-first order by its next session. Never blocks or stalls the current open.- if (chosen.kind !== 'chrome-for-testing' && !global._cftPrewarmKicked && process.platform === 'win32') {+ if (chosen.kind !== 'chrome-for-testing' && !global._cftPrewarmKicked && (process.platform === 'win32' || process.platform === 'darwin')) { global._cftPrewarmKicked = true; try { console.log(`[prewarm] branded fallback (${chosen.kind}) — starting background CfT install so future opens use Chrome for Testing`);@@ -4801,7 +4903,7 @@ async function launchSession(sessionId, url, { freshProfile = false, profile, st // full-size, z-bottomed, and z-confirmed. Only for a freshly launched browser (_needsPark) — // a reused window is already placed, and re-parking it could hide a window the user has up. const kickoffPark = () => {- if (foreground || process.platform !== 'win32') return;+ if (foreground || (process.platform !== 'win32' && process.platform !== 'darwin')) return; try { const be = browsers.get(profileName); if (be && be.browser && be.browser._needsPark) {@@ -6523,6 +6625,32 @@ const server = http.createServer(async (req, res) => { const session = sessions.get(sessionId); try { const stop = !!(args.stop || args.clear);+ // ── macOS ── there is no taskbar to flash and macOS has no API to bounce ANOTHER+ // app's Dock tile. The mac analog: unhide the window at its z-position (visible but+ // NOT focused — the non-intrusive nudge) + a native AD toast whose click focuses it.+ // {stop} dismisses the toast.+ if (process.platform === 'darwin') {+ const sid = session._sessionId || sessionId;+ const toastId = `pup-alert-${sid}`;+ let applied = false;+ if (stop) {+ try { const r2 = await chrome.adCommand('notify_dismiss', { id: toastId }, { timeoutMs: 5000 }); applied = !!r2; } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: false, cleared: applied, applied, clearMethod: applied ? 'toast-dismissed' : null, _hint: applied ? 'Alert toast dismissed.' : 'No toast to dismiss (or AD unreachable).' }) });+ return;+ }+ try { await macSetAppHiddenByPid(macSessionPid(session), false); } catch (e) {}+ try {+ const r2 = await chrome.adCommand('notify_user', {+ id: toastId, level: 'info', scenario: 'reminder',+ title: 'A pup window wants your attention',+ body: `Session "${sid}" has something to show — click to bring it up.`,+ focus: { titleContains: `(session: ${sid}` },+ }, { timeoutMs: 6000 });+ applied = !!(r2 && r2.success !== false);+ } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied, clearMethod: null, _hint: applied ? 'macOS nudge: window un-hidden (not focused) + a native toast posted — its click focuses the window. Pass {stop:true} to dismiss the toast.' : 'Window un-hidden, but the AD toast could not be posted (AD unreachable or notify unsupported).' }) });+ return;+ } // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native). const r = await flashViaAD(session._sessionId || sessionId, { stop }); let applied = false;@@ -6566,7 +6694,14 @@ const server = http.createServer(async (req, res) => { // a deliberate re-park, so clear the user-foreground latch too. const be = browsers.get(session.profileName); if (be && be.browser) be.browser._userForeground = false;- const ok = (be && be.browser) ? (osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground) === 'backgrounded') : osSendWindowToBack(session.profileName);+ let ok;+ if (process.platform === 'darwin') {+ // macOS lower = hide the session's browser process (still fully drivable — CDP+ // keeps rendering hidden windows). Per-process, so shared-profile siblings hide too.+ ok = await macSetAppHiddenByPid(macSessionPid(session), true);+ } else {+ ok = (be && be.browser) ? (osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground) === 'backgrounded') : osSendWindowToBack(session.profileName);+ } session._foreground = false; sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) }); return;@@ -6635,6 +6770,13 @@ const server = http.createServer(async (req, res) => { // handing the hwnd to AD, because backgrounded pup windows carry WS_EX_NOACTIVATE — structurally // unable to take focus until that style is CLEARED, which is exactly what Raise() does. let _raisedByHwnd = false;+ if (process.platform === 'darwin') {+ // macOS raise = unhide the session's process + make it frontmost. The CDP+ // bounds/maximize above already sized it; System Events does the activation.+ const pid = macSessionPid(session);+ _raisedByHwnd = await macActivateByPid(pid);+ console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${_raisedByHwnd}`);+ } if (process.platform === 'win32') { try { const fw = adPayload(await chrome.adCommand('desktop_find_window', { titleContains: `(session: ${sessionId}` }, { timeoutMs: 5000 }));@@ -8361,7 +8503,19 @@ function detectFFmpeg() { const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] }); if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; } } catch {}- // 2. Common Windows install paths+ // 2. Common macOS install paths (Homebrew arm64/x64, MacPorts) — AD spawns the bridge+ // outside a login shell, so /opt/homebrew/bin is usually NOT on PATH here.+ if (process.platform === 'darwin') {+ for (const p of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/local/bin/ffmpeg']) {+ try {+ if (fs.existsSync(p)) {+ const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+ if (r.status === 0) { FFMPEG_PATH = p; return; }+ }+ } catch {}+ }+ }+ // 3. Common Windows install paths if (process.platform === 'win32') { const candidates = [ 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',@@ -8407,7 +8561,7 @@ function detectFFmpeg() { } detectFFmpeg(); if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);-else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via 'winget install Gyan.FFmpeg' to enable Duration metadata.`);+else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via '${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}' to enable Duration metadata.`); /** * Remux a WebM file in place to add a SegmentDuration tag.@@ -8872,7 +9026,7 @@ async function tabRecordStartImpl({ }) { if (!FFMPEG_PATH) detectFFmpeg(); // lazy re-scan: ffmpeg may have been installed since the bridge started if (!FFMPEG_PATH) {- throw new Error('ffmpeg not found on Windows. Install it (winget install Gyan.FFmpeg), then just retry — the bridge re-detects it, no restart needed.');+ throw new Error(`ffmpeg not found. Install it (${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}), then just retry — the bridge re-detects it, no restart needed.`); } const session = sessions.get(sessionId);