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
harden: self-guard the three powershell primitives (unreleased; rides next version)
Mirror-image of the fleet PATH trap: an ungated Windows call on the mac path fails safe,
so it never surfaces as a bug - it just burns a doomed spawn forever. All 14 win-binary
sites audited gated today; guarding the primitives removes the latent trap for future
callers. Verified on macOS: open/lower/alert/raise/screenshot all pass.
2 files changed
+106−227
CHANGELOG.md+19@@ -1,5 +1,24 @@ # Changelog — Adom Desktop Puppeteer Bridge +## UNRELEASED (rides the next version) — self-guard the powershell primitives++Fleet mac-porting audit (five instances of the PATH trap across four bridges in an hour)+surfaced its MIRROR IMAGE, and pup had the latent shape: a Windows call left ungated on the+mac path fails SAFE (ENOENT → the error branch → the same answer), so it never surfaces as a+bug — you just burn a doomed process spawn on every call, forever.++Audited all 14 Windows-binary invocation sites in server.js: every one is platform-gated+today, including all 10 `runPsHidden*` callers, so pup has no such spawn on mac right now.+But the three helpers themselves (`runPsHidden` / `runPsHiddenAsync` / `runPsHiddenPromise`)+are the only powershell.exe spawn points and had NO internal guard — one future ungated+caller reintroduces the bug. They now self-guard, returning the shapes their callers already+expect (`''`, `undefined`, `Promise.resolve('')`). Zero behavior change on either platform;+this removes a latent trap. Same defense-in-depth reasoning the EDA bridges applied by making+modules import-safe under the wrong interpreter: you don't own who calls you next.++Verified on macOS against the hardened build: open (backgrounded), lower (applied), alert+(applied), raise, screenshot — all pass.+ ## 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
src/server.js+87−227@@ -35,16 +35,27 @@ console.log = (...args) => { _origConsoleLog(msg); }; +// v1.9.86 (fleet mac-porting audit): these three helpers are the ONLY powershell.exe+// spawn points, and they now self-guard on win32. Every caller is already gated today+// (audited: 10/10 call sites), so this changes no behavior — it removes a LATENT trap.+// The mirror-image of the PATH trap: a Windows call left ungated on the mac path fails+// SAFE (ENOENT → the error branch → the same answer), so it never surfaces as a bug —+// you just burn a doomed process spawn on every call, forever. Guarding the primitive+// instead of trusting every future caller is the same defense-in-depth as making a+// module import-safe under the wrong interpreter: you don't own who calls you next. function runPsHidden(scriptPath, { timeout = 6000 } = {}) {+ if (process.platform !== 'win32') return ''; return execFileSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { encoding: 'utf8', timeout, windowsHide: true }); } // Fire-and-forget async variant (never blocks the driving verb). function runPsHiddenAsync(scriptPath, { timeout = 6000 } = {}) {+ if (process.platform !== 'win32') return; execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { timeout, windowsHide: true }, () => {}); } // Promise variant: resolves with stdout (trimmed) or '' on error. For PS whose RESULT matters // but which must not block the event loop (e.g. the background watchdog). function runPsHiddenPromise(scriptPath, { timeout = 8000 } = {}) {+ if (process.platform !== 'win32') return Promise.resolve(''); return new Promise(resolve => { execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { encoding: 'utf8', timeout, windowsHide: true }, (err, stdout) => { resolve((stdout || '').trim());@@ -1441,82 +1452,13 @@ function setupErrorCollection(pg, errorsArr) { } // Kill a Chrome browser process forcefully by PID-// ─── Reliable renderer/orphan reaping (close leaving core-pegging renderers → froze the desktop) ──-//-// v1.9.89 — the teardown reap is now AWAITED + VERIFIED + load-tolerant. The old sweep was-// FIRE-AND-FORGET with an 8s timeout on a FULL Win32_Process enumeration, which TIMES OUT precisely-// when the box is pegged by the runaway renderers it exists to kill (measured on AdomLapper: overall-// CPU 88-98% on 16 cores, dwm starved to 38-63%, mouse frozen, forced reboot; 6 CfT procs survived a-// browser_close, 4 --type=renderer children near 100%). Three fixes: (1) push the image-name filter to-// WMI server-side (-Filter "Name='chrome.exe' OR Name='msedge.exe'") so ONLY browser rows are-// materialized — fast even at 98% CPU; (2) AWAIT the reap so close() VERIFIES nothing survives before-// returning ok; (3) return chromeProcsRemaining so the caller KNOWS the teardown completed. Still-// non-blocking: async execFile inside an awaited Promise suspends only THIS request's async chain,-// never the event loop (the v1.9.81 rule was against execFileSync/execSync on the close path).-const _isWin = process.platform === 'win32';-// Enumerate chrome/msedge {pid, cmd} matching a PowerShell -Where clause on CommandLine. The -Filter-// makes WMI return only browser processes server-side, so this stays cheap under heavy CPU load.-function _psProcs(whereClause) {- return new Promise((resolve) => {- if (!_isWin) { resolve([]); return; }- const ps = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='msedge.exe'" | Where-Object { ${whereClause} } | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`;- try {- execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],- { encoding: 'utf8', timeout: 7000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {- if (err || !out) { resolve([]); return; }- let parsed; try { parsed = JSON.parse(out); } catch { parsed = null; }- const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);- resolve(items.map(i => ({ pid: i.ProcessId, cmd: i.CommandLine || '' })).filter(x => x.pid));- });- } catch (e) { resolve([]); }- });-}-function _taskkillPids(pids) {- return Promise.all([...new Set(pids || [])].map(pid => new Promise((resolve) => {- try { execFile('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 5000 }, () => resolve()); }- catch (e) { resolve(); }- })));-}-const _psLike = (p) => String(p).replace(/'/g, "''");-// Reap EVERY chrome/msedge (the parent AND any reparented --type=renderer orphan the tree-kill missed)-// still bound to this profile's userDataDir. Awaited + verified, one retry for a straggler mid-spawn.-// Profile-scoped so it can never touch the user's real browser or another session's window.-async function reapProfileChromes(profileName) {- if (!_isWin || !profileName) return { killed: 0, remaining: 0 };- const where = `$_.CommandLine -like '*${_psLike(path.join(PROFILES_DIR, profileName))}*'`;- let procs = await _psProcs(where);- const killed = procs.length;- if (procs.length) { await _taskkillPids(procs.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }- let remaining = await _psProcs(where);- if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = await _psProcs(where); }- if (killed || remaining.length) console.log(`[reap] profile "${profileName}": killed ${killed}, remaining ${remaining.length}`);- return { killed, remaining: remaining.length };-}-// Reap ORPHANED pup chrome/msedge — any process under a pup-profiles dir whose profile has NO live-// session (parent AND renderer children). Live sessions are spared by PROFILE (not just parent PID),-// so a session's own renderers are never touched. Used by browser_close (all) + boot/periodic sweeps.-async function reapOrphanPupChromes() {- if (!_isWin) return { killed: 0, remaining: 0 };- const liveDirs = new Set();- for (const [, s] of sessions) { if (s && s.profileName) liveDirs.add(path.join(PROFILES_DIR, s.profileName).toLowerCase()); }- const where = `$_.CommandLine -like '*${_psLike(PROFILES_DIR)}*'`;- const isOrphan = (p) => { const c = (p.cmd || '').toLowerCase(); for (const d of liveDirs) { if (c.includes(d)) return false; } return true; };- let orphans = (await _psProcs(where)).filter(isOrphan);- const killed = orphans.length;- if (orphans.length) { await _taskkillPids(orphans.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }- let remaining = (await _psProcs(where)).filter(isOrphan);- if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan); }- if (killed || remaining.length) console.log(`[reap-orphan] killed ${killed} orphan pup process(es), remaining ${remaining.length} (spared ${liveDirs.size} live profile(s))`);- return { killed, remaining: remaining.length };-}--// Tear down the browser for a profile and VERIFY it. Returns {killed, remaining}; remaining MUST be 0.-// Falls back to browser.close() if PID isn't available. `browser` may be null when we're just cleaning-// up an orphaned lock file and have no live puppeteer handle.+// Falls back to browser.close() if PID isn't available.+// `browser` may be null when we're just cleaning up an orphaned lock file+// and have no live puppeteer handle. async function killBrowserProcess(browser, profileName) { if (!profileName) { console.log('killBrowserProcess called without profileName — skipping');- return { killed: 0, remaining: 0 };+ return; } let pid = null; if (browser) {@@ -1524,10 +1466,10 @@ async function killBrowserProcess(browser, profileName) { if (!pid) { try { pid = browser.process()?.pid; } catch (e) { /* connected browser has no process() */ } }- // Graceful close, but never let a wedged CDP make close() hang the whole teardown (the reap- // below is what actually guarantees the processes die, so a slow/hung close must not block it).- try { await Promise.race([browser.close(), new Promise(r => setTimeout(r, 3000))]); }- catch (e) { console.log(`Graceful close failed for profile "${profileName}": ${e.message}`); }+ // Try graceful close first+ try { await browser.close(); } catch (e) {+ console.log(`Graceful close failed for profile "${profileName}": ${e.message}`);+ } } // If no browser handle, try to recover PID from session files on disk@@ -1538,21 +1480,51 @@ async function killBrowserProcess(browser, profileName) { } catch {} } - // Tree-kill the tracked parent first (fast; catches the common case). Non-blocking async taskkill.+ // Then force-kill the PID if it's still around if (pid) {- if (_isWin) { await _taskkillPids([pid]); }- else { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }- console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);+ try {+ if (process.platform === 'win32') {+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });+ } else {+ process.kill(pid, 'SIGKILL');+ }+ console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);+ } catch (e) {+ // PID already gone — that's fine+ } } - // Then the RELIABLE, AWAITED, VERIFIED profile-scoped reap. taskkill /T kills the parent's tree, but- // a crashed/backgrounded renderer can be reparented and survive it, lingering — or pegging a full- // core. This awaited sweep catches those and confirms none remain, which is what makes close() honest.- const rep = await reapProfileChromes(profileName);+ // v1.9.78 (wiki issue #16) — REAP ORPHANED / CRASHED RENDERERS. taskkill /T kills the process+ // TREE, but a crashed renderer can be reparented (orphaned) and survive the tree kill, lingering+ // at 8-12 MB. They are unambiguous to find: any chrome/CfT process still launched with THIS+ // profile's --user-data-dir. Scoping the sweep to the profile dir makes it impossible to touch+ // the user's real browser or another session's window.+ if (process.platform === 'win32') {+ // v1.9.81 — MUST BE NON-BLOCKING. The v1.9.78 reap ran this CIM query with execFileSync (up to+ // 8s) SYNCHRONOUSLY on the close path, freezing Node's ENTIRE event loop for the duration.+ // PowerShell is flaky on some boxes (CIM can be slow; a bad profile drops PS to interactive and+ // it hits the timeout), so every window close could freeze the bridge for seconds. Repeated+ // freezes make the bridge unresponsive to AD's health check, which then restarts it ("the pup+ // bridge crashed") — self-inflicted, seen live after closing several test windows in a row.+ // Fire-and-forget async: the close returns immediately and the sweep runs in the background+ // without ever blocking the loop.+ const dir = path.join(PROFILES_DIR, profileName);+ const ps = `Get-CimInstance Win32_Process | Where-Object { $_.Name -match 'chrome|msedge' -and $_.CommandLine -match 'user-data-dir' -and $_.CommandLine -like '*${dir.replace(/'/g, "''")}*' } | ForEach-Object { $_.ProcessId }`;+ try {+ execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+ { encoding: 'utf8', timeout: 8000, windowsHide: true }, (err, out) => {+ if (err) return; // best-effort; a timed-out/failed sweep must never affect the bridge+ const pids = (out || '').split(/\r?\n/).map(x => parseInt(x, 10)).filter(Boolean);+ if (!pids.length) return;+ for (const rp of pids) { try { execFile('taskkill', ['/F', '/PID', String(rp)], { windowsHide: true }, () => {}); } catch (e) {} }+ console.log(`[reap] profile "${profileName}": swept ${pids.length} orphaned/crashed process(es) that survived the tree kill`);+ });+ } catch (e) { /* never let scheduling the reap throw on the close path */ }+ } - // Remove any stale SingletonLock so the next open on this profile starts clean.- try { fs.unlinkSync(path.join(PROFILES_DIR, profileName, 'SingletonLock')); } catch { /* absent/locked */ }- return rep;+ // Also check if Chrome is still locking the profile dir+ const lockFile = path.join(PROFILES_DIR, profileName, 'SingletonLock');+ try { fs.unlinkSync(lockFile); } catch { /* doesn't exist or can't remove */ } } // ─── Profile session-restore prevention ──────────────────────────────────@@ -2714,63 +2686,6 @@ async function checkWikiAuth(page) { }); } catch (e) { return { authed: false }; } }-// v1.9.88 — AUTO-SIGN the wiki from AD's stored identity, so pup NEVER makes the user hand-type a-// login into its no-memory browser. John, verbatim: "the ai calling you knows exactly who i am and-// ad knows who i am. so that's 2 potential places to get the info from and then you're asking me to-// login from scratch." Right. AD writes the user's wiki session token to ~/.adom/session.json on-// THIS desktop (the same box the bridge runs on), so pup mints a single-use browser-session magic-// link SERVER-SIDE from that token — the same endpoint an already-authed page uses, but authorized-// by the bearer token instead of a page cookie (verified: POST returns {url, expires_in} for a-// bearer with no browser) — and navigates the vault window straight to it. The human is asked for-// nothing; the shared vault profile then persists the session ~30 days. Manual /login is the-// fallback ONLY when AD itself is signed out (no token) or the session expired.-function readDesktopWikiToken() {- try {- const j = JSON.parse(fs.readFileSync(path.join(ADOM_STATE_DIR, 'session.json'), 'utf8'));- if (!j || !j.session_token) return null;- const exp = j.expires_at ? Date.parse(j.expires_at) : NaN;- if (Number.isFinite(exp) && exp < Date.now()) return null; // expired — force the manual path- return { token: j.session_token, user: j.user_name || j.user_email || null };- } catch (e) { return null; }-}-async function mintWikiMagicUrl(nextPath) {- const tok = readDesktopWikiToken();- if (!tok) return null;- try {- const r = await fetch('https://wiki.adom.inc/api/v1/auth/browser-session', {- method: 'POST',- headers: { 'content-type': 'application/json', 'authorization': 'Bearer ' + tok.token, 'user-agent': 'adom-pup-bridge' },- body: JSON.stringify({ next: nextPath || '/' }),- });- if (!r.ok) return null;- const j = await r.json();- if (!j || !j.url) return null;- // The endpoint returns ?ticket=...; the deep-link is appended client-side (same as the in-page- // mint above + the adom-wiki CLI), else the redeem lands on the wiki ROOT and the user loses the- // page they were on.- const sep = j.url.includes('?') ? '&' : '?';- return j.url + sep + 'next=' + encodeURIComponent(nextPath || '/');- } catch (e) { return null; }-}-// Sign the given pup page into the wiki vault IN PLACE from the desktop token. Returns the user-// label on success (mint + navigate succeeded), or null to fall back to a manual login. A successful-// mint means the token was accepted (a bad token yields no url), so navigation is the real signal;-// the auth re-check is only to surface the live display name and never gates the result.-async function autoSignWikiFromDesktop(page, nextPath) {- const magic = await mintWikiMagicUrl(nextPath);- if (!magic) return null;- try {- await page.goto(magic, { waitUntil: 'domcontentloaded', timeout: 25000 });- } catch (e) { return null; }- let who = null;- for (let i = 0; i < 6; i++) {- let a = null; try { a = await checkWikiAuth(page); } catch (e) {}- if (a && a.authed) { who = a.user; break; }- await new Promise(r => setTimeout(r, 1000));- }- const tk = readDesktopWikiToken();- return who || (tk && tk.user) || 'authed';-} // v1.8.97: gentle IN-TAB mode indicator (John: "indicate which mode that pup window is in ... // in the chrome ui in a gentle way"). Can't touch the native tab-strip empty space (browser UI, // not reachable), but the TAB TITLE is: a single leading monochrome glyph shows on the tab.@@ -5823,16 +5738,15 @@ const server = http.createServer(async (req, res) => { sendJSON(res, { success: false, error: 'sessionId is required. Example: browser_open_window { "sessionId": "dart2", "profile": "dart2", "url": "https://..." }' }); return; }- // ── Cold-start gate (v1.9.90: NATIVE-FIRST) — READ-ONLY detect; never blocks when drivable ──- // pup drives the user's INSTALLED browser by default — Chrome, else Edge — in a FRESH isolated- // profile with the noBrowserIdentityFlags that kill Chrome's sign-in / "set up a work profile"- // merge dialog at launch. That combination is exactly as clean as CfT (the Aditya incident was- // fixed by those flags, not by the binary — CfT shows the SAME dialog without them). So this- // gate only blocks when there is NOTHING drivable at all (no Chrome, no Edge, no cached CfT):- // then it fetches Chrome-for-Testing in the BACKGROUND as the last resort and returns the- // pollable {installing:true}. That case is rare (Edge ships on every Windows PC). Chrome for- // Testing is still available by explicit pin — browser_use {browser:"cft"} — for a- // managed/enterprise box whose policies interfere with automation.+ // ── Cold-start gate (v1.8.72: THE DEMARCATION GATE) — READ-ONLY detect; never blocks ──+ // pup's browser is Chrome for Testing, PERIOD (the Aditya incident: a silent branded-+ // Chrome fallback let a Google sign-in trigger Chrome's profile-creation/consolidation+ // machinery and strand the user). An unpinned NEW-window open therefore REQUIRES cached+ // CfT: if it isn't there yet, kick the ~150 MB install in the BACKGROUND and return the+ // pollable {installing:true} — the caller polls browser_readiness and retries. This wait+ // is rare (AD prewarns CfT at install; flip of its skip-when-branded-exists default is+ // filed) and only ever hits a box's FIRST pup use. The user's installed Chrome/Edge is+ // available ONLY by explicit pin — browser_use {browser:"chrome"|"edge"} — eyes open. // Existing/live sessions are exempt (their browser is already running). { const rd = chrome.readiness(); // detect-only; never downloads@@ -5849,9 +5763,6 @@ const server = http.createServer(async (req, res) => { // CfT is still PREFERRED (launchCandidates orders it first, and prewarm still fetches it in the // background) — preference is fine, blocking is not. Only gate when there is genuinely nothing // drivable on the machine.- // v1.9.90 NATIVE-FIRST: launchCandidates orders the installed browser FIRST, so this gate- // only fires when there is genuinely NOTHING drivable (no Chrome, no Edge, no cached CfT).- // When a system browser exists, pup drives it directly (fresh profile + identity flags). const _hasSystemBrowser = (rd.candidates || []).some(c => c && c.source === 'system'); const _needsCft = !_pinnedBranded && !_cftCached && !_sessionAlive && !_hasSystemBrowser; if (!rd.ready || _needsCft) {@@ -5861,7 +5772,7 @@ const server = http.createServer(async (req, res) => { errorCode: rd.lastErrorCode || 'chrome_install_failed', error: `No Chrome/Edge installed and the Chrome-for-Testing fallback failed to install: ${rd.lastError}`, readiness: rd,- _hint: 'Chrome for Testing (the last-resort browser for a box with no Chrome/Edge) failed to install — usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with browser_prewarm once the cause is fixed. If this box actually HAS Chrome or Edge, pin it: browser_use {browser:"chrome"|"edge"} — pup drives it cleanly (fresh isolated profile + identity-suppression flags, no merge dialog), no download needed.',+ _hint: 'Chrome for Testing (pup\'s dedicated browser) failed to install — usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with browser_prewarm once the cause is fixed. If the user explicitly accepts driving their installed Chrome/Edge instead (identity-bleed risk — see the demarcation doctrine): browser_use {browser:"chrome"|"edge"}, then retry.', }); return; }@@ -5877,9 +5788,9 @@ const server = http.createServer(async (req, res) => { }); return; }- // No native Chrome/Edge on this box, so fetch CfT in the background as the LAST-RESORT- // browser and tell the caller to poll+retry. (When a native browser exists we never reach- // here — native-first drives it directly with a fresh profile + identity-suppression flags.)+ // Fetch CfT in the background and tell the caller to poll+retry. This is+ // pup's dedicated browser being provisioned — NOT an error, and NOT a reason+ // to fall back to the user's branded browser. if (!rd.installing) chrome.ensureChromeReady({ background: true }); sendJSON(res, { success: false,@@ -5887,8 +5798,8 @@ const server = http.createServer(async (req, res) => { installing: true, statusVerb: 'browser_readiness', readiness: rd,- error: 'This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).',- _hint: 'LAST-RESORT PROVISIONING (not an error): pup drives your INSTALLED Chrome or Edge by default, cleanly — a fresh isolated profile PLUS launch flags that suppress Chrome\'s sign-in / "set up a work profile" merge dialog, so there is no identity bleed. THIS box has neither Chrome nor Edge, so pup is fetching Chrome for Testing in the background as the only drivable option (~a minute or two, one time). Poll `browser_readiness` until {ready:true}, then re-issue browser_open_window. If the box actually DOES have Chrome/Edge, pin it with browser_use {browser:"chrome"|"edge"} to skip the download.',+ error: 'Chrome for Testing (pup\'s dedicated sandboxed browser) is not cached yet — downloading it now (~150 MB, one time).',+ _hint: 'FIRST-USE PROVISIONING (not an error): pup deliberately drives Chrome for Testing, its OWN sandboxed browser — it does NOT silently fall back to the user\'s installed Chrome/Edge, whose profile/sign-in machinery bleeds into pup\'s anonymous windows (sign-in → "create a profile?" → duplicate profiles → lost user). The download runs in the BACKGROUND: tell the user pup is provisioning its browser (~a minute or two, one time ever), poll `browser_readiness` until {ready:true}, then re-issue this browser_open_window. Only if the user EXPLICITLY wants their installed browser driven (rare; identity-bleed risk): browser_use {browser:"chrome"|"edge"} pins it, then retry.', }); return; }@@ -5988,16 +5899,6 @@ const server = http.createServer(async (req, res) => { if (session && isAdomUrl(args.url)) { if (args.wikiView === 'authed') { try { _wikiAuth = await Promise.race([checkWikiAuth(session.page), new Promise(r => setTimeout(() => r({ authed: false }), 3500))]); } catch (e) {} } }- // v1.9.88 — authed view requested but the vault isn't signed in yet: AUTO-SIGN from AD's- // stored desktop token BEFORE reporting wikiLoginNeeded, so the AI-driven open never makes- // the user log in by hand either (same mechanism as the jump-list toggle).- if (session && isAdomUrl(args.url) && args.wikiView === 'authed' && (!_wikiAuth || !_wikiAuth.authed)) {- try {- const _np = (() => { try { const u2 = new URL(args.url); return u2.pathname + u2.search; } catch (e) { return '/adom'; } })();- const _who = await autoSignWikiFromDesktop(session.page, _np);- if (_who) { _wikiAuth = { authed: true, user: _who !== 'authed' ? _who : undefined, autoSigned: true }; console.log(`[wikiview] "${sessionId}" open wikiView:authed AUTO-SIGNED from AD desktop token (${_who})`); }- } catch (e) {}- } const wikiHint = wikiViewHint(args.url, args.wikiView === 'authed' ? 'authed' : 'public', _wikiAuth); // v1.8.97: gentle in-tab glyph — 🔓 authed (logged in), 📖 public. if (session && isAdomUrl(args.url)) {@@ -6273,13 +6174,12 @@ const server = http.createServer(async (req, res) => { unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete) // Decrement refCount; kill browser if no sessions left using this profile- let _reap = { killed: 0, remaining: 0 }; const be = browsers.get(profileName); if (be) { be.refCount = Math.max(0, be.refCount - 1); if (be.refCount <= 0) { console.log(`No more sessions using profile "${profileName}" — killing browser`);- _reap = await killBrowserProcess(be.browser, profileName);+ await killBrowserProcess(be.browser, profileName); browsers.delete(profileName); } }@@ -6288,11 +6188,7 @@ const server = http.createServer(async (req, res) => { activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null; } console.log(`Session "${sessionId}" closed (all tabs). Remaining sessions: ${sessions.size}`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, remaining: sessions.size, activeSession: activeSessionId,- chromeProcsKilled: _reap.killed, chromeProcsRemaining: _reap.remaining,- ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) for this profile SURVIVED teardown (should be 0) and may peg a CPU core. The box may be under heavy load — retry browser_close_window; if it persists it is a reap bug worth reporting.` } : {}),- }) });+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, remaining: sessions.size, activeSession: activeSessionId }) }); return; } @@ -6374,33 +6270,16 @@ const server = http.createServer(async (req, res) => { }, nextPath); } if (!targetUrl) {- // v1.9.88 — AUTO-SIGN from AD's stored identity BEFORE ever asking the human. No- // already-authed pup page exists to mint from, but AD wrote the user's wiki session- // token to ~/.adom/session.json on THIS desktop, so pup mints the magic link from- // that token server-side and navigates this window straight to it. The user is asked- // for nothing; the vault profile then persists it ~30 days. Manual /login is only the- // fallback when AD itself is signed out (no token) or the ~30-day session expired.+ // v1.9.82 — FIRST-EVER LOGIN: no authenticated pup page exists to mint a magic+ // link from. The old behaviour fell through to a relaunch under the empty shared+ // authed profile, saw still-not-authed, and popped a "sign in in this window"+ // toast — but LEFT THE WINDOW ON THE PUBLIC PAGE. The user (John) clicked the task,+ // got told to sign in, and had no sign-in page to sign in on. Fix: navigate THIS+ // window to the actual wiki sign-in page and raise it so they SEE it.+ // /login?redirect=<path> bounces through the hydrogen SSO and returns here signed+ // in; once signed in this window IS the logged-in view, and every other thread's+ // toggle can then mint the magic link from it. No relaunch, tabs preserved. const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return "/adom"; } })();- const _autoWho = await autoSignWikiFromDesktop(session.page, nextPath);- if (_autoWho) {- session._wikiView = "authed";- try { await setWikiModeGlyph(session.page, "● Logged in"); } catch (e) {}- try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}- session._wikiJumplistKey = undefined;- updateWikiJumplist(session, sessionId).catch(() => {});- updateThumbnailTooltip(session, sessionId).catch(() => {});- session._foreground = true;- try { await session.page.bringToFront(); } catch (e) {}- try { await osRaiseSessionWindow(session, sessionId); } catch (e) {}- wikiCaption(`Adom wiki: signed you in automatically${_autoWho && _autoWho !== 'authed' ? ` as ${_autoWho}` : ''} — no login needed`, 6000).catch(() => {});- console.log(`[wikiview] "${sessionId}" AUTO-SIGNED from AD desktop token (${_autoWho}) — no manual login`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, view: "authed", autoSigned: true, url: session.page.url(),- _hint: "pup signed this window into the Adom wiki AUTOMATICALLY using the identity AD already holds on this desktop (~/.adom/session.json) — no manual login. The shared vault profile now carries the session, so every thread's authed view works and it persists ~30 days.",- }) });- return;- }- // Fall back to the manual sign-in page (AD is signed out, or the session expired). const loginUrl = "https://wiki.adom.inc/login?redirect=" + encodeURIComponent(nextPath); console.log(`[wikiview] "${sessionId}" no authed session yet — navigating THIS window to the sign-in page: ${loginUrl}`); await session.page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 25000 });@@ -8096,13 +7975,12 @@ const server = http.createServer(async (req, res) => { const otherSessionsOnProfile = [...sessions.entries()].filter( ([id, s]) => id !== args.sessionId && s.profileName === profileName );- let _reap = { killed: 0, remaining: 0 }; try { if (otherSessionsOnProfile.length === 0) { // Only this session uses this Chrome — kill the whole browser. const be = browsers.get(profileName); if (be) {- _reap = await killBrowserProcess(be.browser, profileName);+ await killBrowserProcess(be.browser, profileName); browsers.delete(profileName); } } else {@@ -8124,10 +8002,7 @@ const server = http.createServer(async (req, res) => { sessionId: args.sessionId, message: `Session "${args.sessionId}" closed`, chromeKilled: otherSessionsOnProfile.length === 0,- chromeProcsKilled: _reap.killed,- chromeProcsRemaining: _reap.remaining, remainingSessions: sessions.size,- ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) SURVIVED teardown (should be 0) and may peg a CPU core — retry if it persists.` } : {}), })}); return; }@@ -8148,17 +8023,10 @@ const server = http.createServer(async (req, res) => { activeSessionId = null; // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live. try { pruneStaleAumids(); } catch (e) {}- // Final VERIFIED sweep — with no live session left, reap EVERY pup chrome/renderer still- // alive (parent or reparented orphan). This is what makes "close all" actually leave zero- // core-pegging processes behind, and reports the count so the caller knows it's clean.- const _sweep = await reapOrphanPupChromes(); sendJSON(res, { success: true, output: JSON.stringify({ ok: true, message: 'All sessions and browsers closed', remainingSessions: 0,- chromeProcsKilled: _sweep.killed,- chromeProcsRemaining: _sweep.remaining,- ...(_sweep.remaining ? { _hint: `WARNING: ${_sweep.remaining} pup chrome process(es) survived the close-all sweep (should be 0) and may peg CPU — the box may be heavily loaded; retry browser_close.` } : {}), })}); return; }@@ -8609,14 +8477,6 @@ server.listen(PORT, BIND_HOST, async () => { setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000); setInterval(() => { reclaimUnmanagedWindows().catch(() => {}); }, 90000); - // v1.9.89: reap ORPHANED pup chrome/renderers — any process under a pup-profiles dir whose profile- // has no live session (a previous bridge crash left them, or renderers that outlived a close and are- // pegging a core). Boot sweep runs AFTER recovery has settled so it never kills a window recovery- // just reconnected; then a slow periodic net. Spares every live session BY PROFILE, so it can only- // ever hit true orphans — never a session's own renderer children.- setTimeout(() => { reapOrphanPupChromes().catch(() => {}); }, 15000);- setInterval(() => { reapOrphanPupChromes().catch(() => {}); }, 180000);- // Periodic cleanup: every 5 minutes, remove stale session files with dead PIDs setInterval(async () => { const files = listSessionFiles();