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
The open-issues sweep, all 7: (#46 P0) janitor and reconciler now verify OS ground truth before any destructive act, the janitor spares any process the OS shows with a visible top-level window plus its profile siblings, and the reconciler spares any session whose OS window is alive and reattaches via rescan, so a derived no-open-windows verdict can never again kill a live browser. (#45) foreground view toggles now use create-then-swap: the replacement window is raised while the old one still holds the foreground lock, then swapped in under the original id, so the window comes back in front. (#48) pup_uninstall declared in bridge.json for ab's uninstall cascade, cascade:true accepted as the non-interactive confirmation. (#47) aiThread-first addressing: window accepted as the new name for the sessionId label, responses carry window and aiThread alongside sessionId, a teaching hint fires when a caller spells out its own thread's window, and the catalog and SKILL.md lead with zero-id addressing. (#44) the toolbar popup's Window-info is now an in-page draggable floaty card in brand tokens with clamped 600ms tooltips teaching the ai-thread ownership model; jump-list clicks keep the ab caption. (#43) new pup_move_to_workspace hands the active tab's URL to Hydrogen via its Browser Picker and closes the pup window, with a toolbar row and jump-list task. Extension 0.2.6.
7 files changed
+454−35
adom-bridge-puppeteer-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "puppeteer",- "version": "2.0.129",- "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.129/adom-bridge-puppeteer-v2.0.129.zip",- "sha256": "9b6c9ab88752195a1982ea80f8863252250065025c1e2051d3f63df5f6628660",- "size": 875277,+ "version": "2.0.130",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.130/adom-bridge-puppeteer-v2.0.130.zip",+ "sha256": "bbf13664bc7a215d751244b0ad9e1ab15fdf474566040281193ff8c50448b325",+ "size": 884627, "verbPrefixes": [ "browser_", "desktop_recorder_",
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-2.0.129+2.0.130
src/bridge.json+3−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "puppeteer", "displayName": "Puppeteer (Chrome for Testing + Native)",- "version": "2.0.129",+ "version": "2.0.130", "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 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 \u2192 Edge \u2192 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. pup_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. pup_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, \u2026). 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 \u2014 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 \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_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-bridge", "author": "Adom Inc.",@@ -125,5 +125,6 @@ "seconds": 90 } ]- }+ },+ "uninstall": "pup_uninstall" }
src/extension/manifest.json+1−1@@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Adom Pup",- "version": "0.2.5",+ "version": "0.2.6", "description": "Toolbar for Adom Pup browser windows: annotate, snip, view toggle, dashboard, owner info. Thin trigger surface - the pup bridge is the engine.", "action": { "default_popup": "popup.html",
src/extension/popup.html+3@@ -71,6 +71,9 @@ <button class="row" data-act="info"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 11v5M12 8v.5"/></svg> Window info card</button>+ <button class="row" data-act="move">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/><path d="M14 4h6v6"/><path d="M20 4L11 13"/></svg>+ Move to my workspace</button> <div class="sep"></div> <button class="row danger" data-act="close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
src/extension/popup.js+1@@ -74,6 +74,7 @@ async function cmd(port, command, args) { else if (act === 'view') r = await cmd(port, 'pup_wiki_set_view', { ...base, view: view === 'authed' ? 'public' : 'authed' }); else if (act === 'dashboard') await cmd(port, 'pup_dashboard', {}); else if (act === 'info') r = await cmd(port, 'pup_window_info', { ...base });+ else if (act === 'move') r = await cmd(port, 'pup_move_to_workspace', { ...base }); else if (act === 'close') { if (confirm('Close ' + (owner ? owner + "'s" : 'this') + ' pup window?')) r = await cmd(port, 'pup_close_window', { ...base }); } } catch (err) { r = { success: false, error: String(err) }; } // every action answers back (design skill): show a failure instead of vanishing
src/server.js+441−27@@ -2434,6 +2434,38 @@ function _taskkillPids(pids) { catch (e) { resolve(); } }))); }+// v2.0.130 (#46 P0 — janitor killed a live browser mid-annotation): GROUND-TRUTH helpers. The+// doctrine from that incident: NEVER let a derived model (CDP page handles, session-map state)+// authorize destruction — ask the OS. These two answer "does this pid own a visible top-level+// window?" and "is this hwnd a live, visible window?" straight from user32/the process table.+// Both FAIL SAFE for their callers: on any error the pid-check returns an empty set and the+// hwnd-check returns false, which the call sites treat as "no proof of life" — they then fall+// back to today's behavior, never to a kill they would not otherwise have made.+function _pidsWithVisibleWindow(pids) {+ return new Promise((resolve) => {+ const ids = [...new Set(pids || [])].filter(Number.isFinite);+ if (!_isWin || !ids.length) { resolve(new Set()); return; }+ const ps = `Get-Process -Id ${ids.join(',')} -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -ExpandProperty Id`;+ try {+ execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+ { encoding: 'utf8', timeout: 7000, windowsHide: true }, (err, out) => {+ if (err || !out) { resolve(new Set()); return; }+ resolve(new Set(out.split(/\r?\n/).map(l => Number(l.trim())).filter(Number.isFinite)));+ });+ } catch (e) { resolve(new Set()); }+ });+}+function osHwndAlive(hwnd) {+ return new Promise((resolve) => {+ const h = Number(hwnd);+ if (!_isWin || !Number.isFinite(h) || h <= 0) { resolve(false); return; }+ const ps = `Add-Type -Namespace PW -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);'; if ([PW.U]::IsWindow([IntPtr]${h}) -and [PW.U]::IsWindowVisible([IntPtr]${h})) { 'ALIVE' }`;+ try {+ execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+ { encoding: 'utf8', timeout: 7000, windowsHide: true }, (err, out) => resolve(!err && /ALIVE/.test(out || '')));+ } catch (e) { resolve(false); }+ });+} 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.@@ -2475,10 +2507,42 @@ async function reapOrphanPupChromes() { 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);+ // v2.0.130 (#46 P0 — John lost live windows mid-annotation): "no live session owns this profile"+ // is a DERIVED verdict and it was FALSE once (session churn + respawns reaped the sessions while+ // their windows were open; the janitor then taskkilled the live Chrome under John's cursor).+ // GROUND TRUTH GATE: ask the OS which candidate pids own a visible top-level window. Any pid+ // that does is by definition NOT an orphan — someone can SEE that window — so it is spared, and+ // so is every sibling process sharing its user-data-dir (killing a spared parent's renderers+ // would crash the very window we just refused to kill). Spared windows are left for pup_rescan /+ // the health check to re-adopt. If the PS probe fails it returns an empty set, which only makes+ // this pass as permissive as the old code — never more destructive.+ const _visPids = await _pidsWithVisibleWindow(orphans.map(p => p.pid));+ if (_visPids.size) {+ const sparedDirs = [];+ for (const p of orphans) {+ if (!_visPids.has(p.pid)) continue;+ const m = (p.cmd || '').match(/user-data-dir=(?:"([^"]+)"|(\S+))/i);+ const dir = ((m && (m[1] || m[2])) || '').toLowerCase();+ if (dir) sparedDirs.push(dir);+ }+ const _spared = (p) => {+ if (_visPids.has(p.pid)) return true;+ const c = (p.cmd || '').toLowerCase();+ return sparedDirs.some(d => d && c.includes(d));+ };+ const before = orphans.length;+ orphans = orphans.filter(p => !_spared(p));+ if (before !== orphans.length) {+ console.log(`[reap-orphan] GROUND TRUTH spared ${before - orphans.length} process(es): the OS shows visible top-level window(s) on ${_visPids.size} pid(s) — "orphan" verdict overruled, leaving them for rescan/adoption`);+ recordCleanup(`janitor spared ${before - orphans.length} browser process(es) the session model called orphaned but the OS shows with VISIBLE windows — left alive for re-adoption (the #46 ground-truth gate)`);+ }+ // The retry pass below re-enumerates; make it honor the same sparing.+ var _notSpared = (p) => !_spared(p);+ } else { var _notSpared = () => true; } 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); }+ let remaining = (await _psProcs(where)).filter(isOrphan).filter(_notSpared);+ if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan).filter(_notSpared); } if (killed || remaining.length) { recordCleanup(`reaped ${killed} orphaned pup browser process(es) that no live session owned${remaining.length ? ` (${remaining.length} survived and may still be using CPU)` : ''} — ${liveDirs.size} live profile(s) were spared`); }@@ -4236,6 +4300,14 @@ async function updateWikiJumplist(session, sessionId, opts) { iconPath: _taskIcon, })); }+ // v2.0.130 (#43): move this window's page into the user's Hydrogen workspace (webview via the+ // Browser Picker), then close the pup window. Only meaningful when Hydrogen runs locally; the+ // verb reports hydrogen_unreachable honestly and leaves the window alone if not.+ tasks.push(pupTask('pup_move_to_workspace', { sessionId }, {+ title: 'Move to my workspace',+ description: 'Hand this page to Hydrogen on this machine (opens in your workspace via the Browser Picker) and close this pup window',+ iconPath: _taskIcon,+ })); // The shell ignores a jump list on an unregistered AUMID — ensure it's registered first. // v1.9.314 (John: "i turned on jump lists and none of them work"): this guard REQUIRED the appId to // be in _registeredPupAumids, but that set is ONLY filled by desktop_register_app_identity, which@@ -6940,6 +7012,28 @@ async function reconcileModel(reason) { let anyOpen = false; for (const t of (s.tabs || [])) { try { if (t && t.page && !t.page.isClosed()) { anyOpen = true; break; } } catch (e) {} } if (anyOpen) continue;+ // v2.0.130 (#46 P0): the "no open windows" verdict above is CDP-DERIVED and it lied once —+ // heavy churn (ghost adoption + takeover closes + respawns) left every page handle closed+ // while John's windows were open and visible; this reap then fed the janitor, which killed+ // the live Chrome mid-annotation. GROUND TRUTH before destruction: if the OS still shows+ // this session's window alive + visible, the verdict is wrong — spare the session and try to+ // re-attach via rescan instead. Ground truth failing to find a window (or erroring) falls+ // through to today's reap, so a genuinely closed session still gets cleaned.+ let _osAlive = false;+ if (_isWin) {+ try {+ let h = s._hwnd ? Number(s._hwnd) : null;+ if (h && !(await osHwndAlive(h))) h = null;+ if (!h) { const h2 = await sessionHwnd(sid); if (h2 && (await osHwndAlive(h2))) h = h2; }+ if (h) _osAlive = true;+ if (_osAlive) {+ console.log(`[reconcile] SPARED session "${sid}" — CDP shows no open pages but OS window ${h} is alive+visible (ground truth overrules, #46); attempting rescan re-attach`);+ const be = browsers.get(s.profileName);+ if (be && be.browser) { rescanProfile(be.browser, s.profileName, { adoptOrphans: false }).catch(() => {}); }+ }+ } catch (e) {}+ }+ if (_osAlive) continue; try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {} try { fs.unlinkSync(path.join(require('os').tmpdir(), 'pup-badge-debug', sid + '.png')); } catch (e) {} try { _tabFavCache && s.tabs && s.tabs.forEach(() => {}); } catch (e) {}@@ -8002,7 +8096,7 @@ async function enumAllPupChromeHwnds() { // one session. With sessionId omitted these resolve to the CALLER THREAD's own window (the same // ai-<thread-slug> keying pup_open_window uses) — never to activeSessionId, which is just whichever // window was touched last, usually another thread's.-const SESSION_TARGETING_VERBS = new Set(['pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_open_tab', 'pup_switch_tab', 'pup_close_tab', 'pup_close_window', 'pup_eval', 'pup_evaluate', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_screenshot', 'pup_screenshot_full_res', 'pup_errors', 'pup_list_tabs', 'pup_wait', 'pup_alert_window', 'pup_focus_window', 'pup_raise_os_window', 'pup_lower_os_window', 'pup_window_info', 'pup_record_start', 'pup_record_stop', 'pup_wiki_set_view', 'pup_switch_window', 'pup_annotate', 'pup_floaty_click']);+const SESSION_TARGETING_VERBS = new Set(['pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_open_tab', 'pup_switch_tab', 'pup_close_tab', 'pup_close_window', 'pup_eval', 'pup_evaluate', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_screenshot', 'pup_screenshot_full_res', 'pup_errors', 'pup_list_tabs', 'pup_wait', 'pup_alert_window', 'pup_focus_window', 'pup_raise_os_window', 'pup_lower_os_window', 'pup_window_info', 'pup_record_start', 'pup_record_stop', 'pup_wiki_set_view', 'pup_switch_window', 'pup_annotate', 'pup_floaty_click', 'pup_move_to_workspace']); function threadDefaultSessionId(callerThread) { return 'ai-' + String(callerThread).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); }@@ -9981,6 +10075,24 @@ function sendJSON(res, obj, status = 200) { inner._credentialCaptured = _cs._pendingCredHint; _cs._pendingCredHint = null; touched = true; } } catch (e) {}+ // v2.0.130 (#47, aiThread-first): every response naming a sessionId ALSO carries+ // `window` (the new public name for the per-window label) and `aiThread` (the owning+ // thread), so callers learn the new vocabulary with zero breakage; sessionId stays a+ // back-compat alias. One teaching hint fires when an AI caller spelled out the id of+ // its OWN thread's default window — zero-id addressing would have resolved it.+ try {+ if (inner.sessionId && inner.window === undefined) {+ inner.window = inner.sessionId; touched = true;+ const _os = sessions.get(inner.sessionId);+ const _own = _os && (_os._ownerThread || _os.owner);+ if (_own && inner.aiThread === undefined) inner.aiThread = _own;+ const _ct = res && res._pupCallerThread;+ if (res && res._pupExplicitSid && _ct && !USER_CALLERS.has(_ct) && inner._addressing === undefined+ && _own === _ct && threadDefaultSessionId(_ct) === inner.sessionId) {+ inner._addressing = 'FYI: this is YOUR thread\'s own window — omit the id entirely (zero-id addressing: pup resolves your --ai-thread name to your window). `window` is the new name for this label (sessionId remains a silent alias); pass either only when one thread runs several windows.';+ }+ }+ } catch (e) {} // v1.9.99: on a verb that just pointed a window at content, insist the caller LOOK at it. // Only on success (a failure has its own errorCode + hint to act on first), and never // overwriting a handler that already said something about verification.@@ -11436,6 +11548,14 @@ const server = http.createServer(async (req, res) => { } } }+ // v2.0.130 (#47, the rename campaign): `window` is the NEW public name for the per-window+ // label formerly called sessionId — needed only when one thread runs several windows.+ // Accepted everywhere sessionId is; sessionId stays as a silent back-compat alias forever.+ if (args && !args.sessionId && args.window != null && String(args.window).trim()) args.sessionId = String(args.window).trim();+ // Stash addressing context for the central response enrichment in sendJSON (#47): responses+ // emit window + aiThread alongside sessionId, and a caller that spelled out an id its own+ // thread-name would have resolved gets one teaching hint.+ try { res._pupCallerThread = _callerThread; res._pupExplicitSid = !!(args && args.sessionId); } catch (e) {} // v2.0.103 (John: "dispense with sessionId — the thread name is the handle"): explicit // thread-name addressing. aiThreadName resolves to that thread's window on any targeting verb. if (args && !args.sessionId && (args['ai-thread'] || args.aiThread || args.aiThreadName) && SESSION_TARGETING_VERBS.has(command)) {@@ -11599,6 +11719,16 @@ const server = http.createServer(async (req, res) => { // Verbs tab renders the full self-doc — the reference shape for all bridges. const V = (name, summary, input, timeoutSeconds, example) => { const m = VERB_META[name] || {};+ // v2.0.130 (#47, aiThread-first): the catalog is where callers learn addressing, so fix+ // it in ONE place — any terse/stale sessionId doc ("required str" predates zero-id+ // addressing and is simply wrong now) is rewritten to lead with omission + the new+ // `window` name. Entries with rich bespoke wording (pup_open_window etc.) are left as+ // written. sessionId remains a silent back-compat alias everywhere.+ try {+ if (input && typeof input.sessionId === 'string' && input.sessionId.length < 45) {+ input.sessionId = 'optional str — OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)';+ }+ } catch (e) {} return { name, summary, input, output: { success: 'bool' }, timeoutSeconds, statusVerb: null, longRunning: false, example: example || {}, hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] };@@ -11652,6 +11782,7 @@ const server = http.createServer(async (req, res) => { V('pup_login', 'Fill (and optionally submit) an HTML login form in the CURRENT tab, in the BACKGROUND — no Chrome autofill dropdown, no foregrounding, React/Vue-safe. Creds from the keychain vault (matched on the tab URL; captured automatically as you log in, or stored via credential_set / save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched pages AUTO-FILL on load (_autoLogin on the response). SUBMIT IS REASON-GATED: submit:true requires submitReason AND makes you confirm the live domain (the refusal regurgitates the exact host so a lookalike/phishing site is caught before the secret is sent). pup_configure {autoLogin:"off"} disables auto-fill; {captureLogins:"off"} stops auto-capture.', { sessionId: 'optional str — omit to target the window your own thread owns', username: 'optional str', password: 'optional str — with username, fills these instead of the vault', submit: 'optional bool — submit after filling; REQUIRES submitReason', submitReason: 'required WITH submit:true — why you are confident the CURRENT domain is the correct, intended site to send these credentials to (min 10 chars; the refusal shows you the live host to confirm; logged, auditable)', save: 'optional bool — with explicit creds, store them (OS keychain) so next time is automatic', host: 'optional str — override the vault host key when saving', realKeystrokes: 'optional bool — type with REAL key events instead of a background .value-set. Needed for React-controlled MASKED fields (Gusto/Keycloak SSO) that ignore .value and would submit an empty password. It briefly FOREGROUNDS the window (real keystrokes only register on-screen). Use when a normal fill returned filledPassword:false / reactMaskedFieldSuspected, or when the login-recipe for the site says so.' }, 20, { sessionId: 'work', submit: true, submitReason: 'confirmed the tab is on the real vendor domain I intended to sign into' }), V('pup_credentials', 'List saved logins (host + username only; passwords are in the OS keychain and NEVER returned). pup captures these automatically as you sign into sites and auto-fills them in the background on return visits.', {}, 10, {}), V('pup_reap_profiles', 'Delete LEAKED isolated profile directories — the iso-* throwaway jars minted by isolated:true / wikiView:public opens whose window is long gone (they used to pile up, 100s per machine). Only removes an iso-* dir that no live session or session file owns, that no Chrome is holding, and that is >15 min old; durable jars (adom-you, named profiles) are NEVER touched. Runs automatically every 15 min too. dryRun:true previews the list without deleting.', { dryRun: 'optional bool — preview which iso-* profiles would be removed, delete nothing' }, 20, { dryRun: true }),+ V('pup_move_to_workspace', 'MOVE this pup window\'s page into the user\'s Hydrogen workspace: the active tab\'s URL is handed to Hydrogen (ah) on this machine via its Browser Picker (webview by default, honoring the user\'s per-domain choice), then the pup window closes — its owner initiated the move. keepWindow:true keeps the pup window open too. Needs Hydrogen running locally (hydrogen_unreachable otherwise; the window is left untouched on failure). Cloud Hydrogen (hc) is not yet supported.', { sessionId: 'optional str', keepWindow: 'optional bool — hand the URL over but keep the pup window open' }, 25, {}), V('pup_uninstall', 'DESTRUCTIVE — cleanly remove pup\'s WHOLE footprint from this machine: the HKCU AppUserModelId Adom.Pup registry keys, all staged extensions, ALL pup profiles + sessions, the OS-keychain credential vault, pup-settings.json, the badge-debug temp dir, and the jump-list launcher. ALWAYS preview first with dryRun:true (removes nothing, returns the plan). To actually remove, pass confirm:"uninstall". keepCredentials:true preserves the saved-login vault; keepProfiles:true preserves profiles/sessions. Chrome-for-Testing + firewall rules are SHARED and left alone unless removeChrome:true / removeFirewallRules:true. Closes every window + quits every browser first to release profile locks. (The bridge itself is managed by Adom Bridge — remove pup from Bridge to stop it re-seeding.)', { dryRun: 'optional bool — preview the removal plan without removing anything (do this first)', confirm: 'required str "uninstall" to actually remove', keepCredentials: 'optional bool — keep the saved-login vault', keepProfiles: 'optional bool — keep pup profiles + sessions', removeChrome: 'optional bool — also delete the Chrome-for-Testing install (shared; default keep)', removeFirewallRules: 'optional bool — also remove the CfT firewall rules (needs admin)' }, 60, { dryRun: true }), V('pup_alert_window', 'Nudge the user about a pup window WITHOUT stealing focus — on Windows it flashes the taskbar button; on macOS (no taskbar, and no API to bounce another app\'s Dock tile) it unhides the window at its z-position and posts an AD toast whose click focuses it. Same contract on both, so call it identically; just never tell a Mac user to look for a flashing icon. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window — use this for an explicit end-of-task "it\'s ready" nudge on top of that. STRONGLY prefer this (or the auto-flash) over pup_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.', { sessionId: 'optional str', stop: 'optional bool — CLEAR the highlight instead of flashing' }, 15, {}), V('pup_render_html', 'Render a local HTML string in the tab via page.setContent — UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.', { sessionId: 'required str', html: 'required HTML string', waitUntil: 'optional (domcontentloaded default)', updateNote: 'optional note for lastAgentUpdate', silent: 'optional bool — suppress the auto-flash' }, 30, { sessionId: 'work', html: '<h1>Café</h1>' }),@@ -12451,6 +12582,12 @@ const server = http.createServer(async (req, res) => { // in-process on the desktop (this bridge IS on the machine) via fs + reg.exe. const dry = args && args.dryRun === true; const rmChrome = args && args.removeChrome === true;+ // v2.0.130 (#48, Bridge SDK uninstall cascade): ab's uninstaller enumerates bridges and+ // calls each declared <prefix>uninstall NON-INTERACTIVELY during a silent /S uninstall —+ // it must never be refused. cascade:true is the machine path (declared in bridge.json's+ // uninstall field, per the reply on issue #48); confirm:"uninstall" remains the explicit+ // human/AI path. Both idempotent, best-effort per item, never prompting.+ if (args && args.cascade === true && !args.confirm) args.confirm = 'uninstall'; if (!dry && (!args || args.confirm !== 'uninstall')) { sendJSON(res, { success: false, errorCode: 'confirm_required', _hint: 'pup_uninstall REMOVES pup\'s footprint from this machine (registry AUMID keys, staged extensions, all pup profiles, the login vault, settings, temp + launcher files). Preview first: pup_uninstall {dryRun:true}. To actually remove: pup_uninstall {confirm:"uninstall"}. Keep your saved logins: add keepCredentials:true. Keep profiles/sessions: keepProfiles:true. Chrome-for-Testing and firewall rules are left unless removeChrome:true / removeFirewallRules:true.' }); return;@@ -12705,25 +12842,201 @@ const server = http.createServer(async (req, res) => { if (at) lines.push(`Showing: ${(at.title || at.url || '?').slice(0, 90)}`); if (lu) lines.push(`Last agent action: ${lu.verb}${lu.note ? ` · ${lu.note}` : ''} (${_fmtAge(Math.round((Date.now() - lu.ts) / 60000))} ago)`); if (session._lastTakeoverBy) lines.push(`⚠ last takeover: by "${session._lastTakeoverBy}" ${_fmtAge(Math.round((Date.now() - (session._takenOverAt || Date.now())) / 60000))} ago`);- let captionShown = false;- try {- const r = await chrome.abCommand('desktop_caption', {- id: `pup-info-${sid}`, text: lines.join('\n'),- position: 'bottom', size: 'medium', duration: 12000,- }, { timeoutMs: 8000, caller: sessionCaller(session) });- captionShown = !(r && r.success === false);- } catch (e) {}+ // v2.0.130 (#44, John's design): the TOOLBAR POPUP's Window-info click gets a proper+ // IN-PAGE floaty card (brand tokens, draggable grip, 600ms viewport-clamped tooltips+ // teaching the ai-thread-owns-window model) instead of the always-a-bit-wonky ab desktop+ // caption. Jump-list clicks KEEP the caption (no reliable in-page surface exists from the+ // taskbar); AI/JSON callers get the data either way. Card falls back to the caption if+ // injection fails, so the user never gets nothing.+ const _fromToolbar = _callerThread === 'user-toolbar' || (args.caller && args.caller.aiThread === 'user-toolbar');+ let cardShown = false, captionShown = false;+ if (_fromToolbar) {+ try {+ const _cardTab = (session.tabs || []).find(t => t.tabId === (args._resolvedTabId || session.activeTabId)) || (session.tabs || [])[0];+ const _cardInfo = {+ sid, owner: ownT || 'unclaimed', container: session._ownerContainer || null,+ profile: session.profileName || '?', age: _fmtAge(ageMin),+ tabs: tabs.map(t => ({ label: (t.title || t.url || '?').slice(0, 64), active: !!t.active })),+ last: lu ? `${lu.verb}${lu.note ? ': ' + lu.note : ''} (${_fmtAge(Math.round((Date.now() - lu.ts) / 60000))} ago)` : null,+ takeover: session._lastTakeoverBy ? `by "${session._lastTakeoverBy}" ${_fmtAge(Math.round((Date.now() - (session._takenOverAt || Date.now())) / 60000))} ago` : null,+ };+ await _cardTab.page.evaluate((d) => {+ try {+ const old = document.querySelector('div[data-pup-infocard]');+ if (old) old.remove();+ const host = document.createElement('div');+ host.setAttribute('data-pup-infocard', '1');+ host.style.cssText = 'position:fixed;top:24px;right:24px;z-index:2147483647;margin:0;padding:0;';+ const sh = host.attachShadow ? host.attachShadow({ mode: 'open' }) : null;+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));+ const TIPS = {+ thread: 'The ai-thread is the identifier for pup windows: you get one pup window per ai-thread, and pup default-creates tabs in that window for everything the thread manages or needs a browser surface for.',+ container: 'The cloud container the owning AI thread runs in. That is where the AI driving this window lives.',+ profile: 'The Chrome profile (cookie jar) this window rides. adom-you is the shared signed-in jar every window defaults to; iso-* jars are throwaway anonymous profiles.',+ tabs: 'Every tab in this window. The owning thread adds tabs here instead of opening new windows, so one taskbar button holds all its pages.',+ age: 'How long this window has been open. pup windows persist until their thread (or you) closes them.',+ last: 'The most recent pup action the owning AI ran against this window, with the note it left.',+ takeover: 'Another thread claimed this window from its previous owner. Takeovers are logged and only allowed when the user asked.',+ };+ let rows = '';+ const row = (k, label, val, accent) => { rows += `<div class="r" data-tip="${esc(TIPS[k] || '')}"><span class="k">${esc(label)}</span><span class="v${accent ? ' a' : ''}">${esc(val)}</span></div>`; };+ row('thread', 'ai-thread', d.owner, true);+ if (d.container) row('container', 'container', d.container);+ row('profile', 'profile', d.profile);+ row('age', 'open', d.age);+ if (d.last) row('last', 'last action', d.last);+ if (d.takeover) row('takeover', 'takeover', d.takeover);+ let tabsHtml = '';+ for (const t of (d.tabs || [])) tabsHtml += `<div class="t${t.active ? ' on' : ''}">${t.active ? '▸ ' : ''}${esc(t.label)}</div>`;+ const html = `<style>+ .card{width:340px;max-width:calc(100vw - 48px);background:#0d1117;border:1px solid #30363d;border-radius:10px;box-shadow:0 12px 34px rgba(0,0,0,.55);font:12px/1.45 "Segoe UI",system-ui,sans-serif;color:#c9d1d9;overflow:hidden;user-select:none}+ .hd{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#161b22;border-bottom:1px solid #30363d;cursor:grab}+ .hd:active{cursor:grabbing}+ .grip{color:#8b949e;font-size:13px;letter-spacing:1px}+ .ttl{font-weight:600;color:#e6edf3;flex:1}+ .x{cursor:pointer;color:#8b949e;padding:2px 6px;border-radius:5px;font-size:13px}+ .x:hover{color:#e6edf3;background:#21262d}+ .bd{padding:8px 10px}+ .r{display:flex;gap:8px;padding:3px 2px;border-radius:5px}+ .r:hover{background:#161b22}+ .k{color:#8b949e;min-width:78px}+ .v{color:#c9d1d9;flex:1;word-break:break-word}+ .v.a{color:#00b8b1;font-weight:600}+ .tabs{margin-top:6px;border-top:1px solid #21262d;padding-top:6px}+ .t{color:#8b949e;padding:2px 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .t.on{color:#e6edf3}+ .teach{margin-top:8px;padding:7px 9px;background:#0b2b29;border:1px solid rgba(0,184,177,.35);border-radius:7px;color:#7fe9e2;font-size:11px}+ .tip{position:fixed;z-index:2147483647;max-width:260px;background:#161b22;border:1px solid #30363d;border-radius:7px;padding:7px 9px;color:#c9d1d9;font-size:11px;line-height:1.45;box-shadow:0 8px 22px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .18s ease;text-transform:none}+ .tip.show{opacity:1}+ </style>+ <div class="card">+ <div class="hd" data-drag><span class="grip">⠿</span><span class="ttl">Adom Pup: window "${esc(d.sid)}"</span><span class="x" data-x title="Close">✕</span></div>+ <div class="bd">${rows}<div class="tabs" data-tip="${esc(TIPS.tabs)}">${tabsHtml}</div>+ <div class="teach">One pup window per ai-thread: the thread named above owns this window, and everything it needs a browser for opens as a tab here.</div></div>+ </div><div class="tip" data-tip-el></div>`;+ if (sh) sh.innerHTML = html; else host.innerHTML = html;+ const root = sh || host;+ document.documentElement.appendChild(host);+ // close+ root.querySelector('[data-x]').addEventListener('click', () => host.remove());+ // drag by the grip header, clamped to the viewport+ const hd = root.querySelector('[data-drag]');+ let dx = 0, dy = 0, dragging = false;+ hd.addEventListener('pointerdown', (e) => {+ if (e.target.closest('[data-x]')) return;+ dragging = true; const r = host.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top;+ try { hd.setPointerCapture(e.pointerId); } catch (e2) {}+ });+ hd.addEventListener('pointermove', (e) => {+ if (!dragging) return;+ const w = host.offsetWidth, h = host.offsetHeight;+ const L = Math.max(8, Math.min(innerWidth - w - 8, e.clientX - dx));+ const T = Math.max(8, Math.min(innerHeight - h - 8, e.clientY - dy));+ host.style.left = L + 'px'; host.style.top = T + 'px'; host.style.right = 'auto';+ });+ hd.addEventListener('pointerup', () => { dragging = false; });+ // tooltip singleton: 600ms delay, clamped to the viewport on BOTH axes+ const tip = root.querySelector('[data-tip-el]');+ let tmr = null;+ root.querySelectorAll('[data-tip]').forEach((el) => {+ el.addEventListener('mouseenter', (e) => {+ const txt = el.getAttribute('data-tip');+ if (!txt) return;+ clearTimeout(tmr);+ tmr = setTimeout(() => {+ tip.textContent = txt;+ tip.classList.add('show');+ // measure AFTER content set+ const tw = tip.offsetWidth, th = tip.offsetHeight, M = 8;+ let x = e.clientX + 14, y = e.clientY + 14;+ if (x + tw + M > innerWidth) x = e.clientX - tw - 14;+ if (y + th + M > innerHeight) y = e.clientY - th - 14;+ x = Math.max(M, Math.min(innerWidth - tw - M, x));+ y = Math.max(M, Math.min(innerHeight - th - M, y));+ tip.style.left = x + 'px'; tip.style.top = y + 'px';+ }, 600);+ });+ el.addEventListener('mouseleave', () => { clearTimeout(tmr); tip.classList.remove('show'); });+ });+ } catch (e) {}+ }, _cardInfo);+ cardShown = true;+ } catch (e) { console.log(`[window-info] in-page card injection failed (${e.message}) — falling back to the ab caption`); }+ }+ if (!cardShown) {+ try {+ const r = await chrome.abCommand('desktop_caption', {+ id: `pup-info-${sid}`, text: lines.join('\n'),+ position: 'bottom', size: 'medium', duration: 12000,+ }, { timeoutMs: 8000, caller: sessionCaller(session) });+ captionShown = !(r && r.success === false);+ } catch (e) {}+ } sendJSON(res, { success: true, sessionId: sid, owner: ownT, ownerContainer: session._ownerContainer || null, profile: session.profileName || null, ageMinutes: ageMin, tabs, lastAgentUpdate: lu, foreground: !!session._foreground, lastTakeoverBy: session._lastTakeoverBy || null,- captionShown,- _hint: captionShown- ? 'A native card with this info is showing just above the taskbar for ~12s (click-through, auto-dismisses).'- : 'Caption could not be shown (desktop_caption unavailable on this ab?) — the same info is in this JSON.',+ cardShown, captionShown,+ _hint: cardShown+ ? 'An in-page floaty card with this info is showing in the window (draggable by its grip, hover any row for its explanation, ✕ to close).'+ : (captionShown+ ? 'A native card with this info is showing just above the taskbar for ~12s (click-through, auto-dismisses).'+ : 'Neither the in-page card nor the caption could be shown — the same info is in this JSON.'), }); return; }+ // v2.0.130 (#43, ah leg revived on John's "do them all"): MOVE this pup window's page into+ // the user's Hydrogen workspace. Design: docs/PLAN-interactive-surface.md Phase 3. The+ // plan's direct workspace-tabs endpoint (port 47083) is not exposed by today's Hydrogen, so+ // this rides the REAL current API: ab's hd_api -> Hydrogen control API POST /open-url,+ // which routes through the Browser Picker (per-domain memory; webview is the default+ // surface, exactly the "move to my workspace" semantic). On success the pup window closes+ // (its owner initiated the move) unless keepWindow:true. hc (cloud) stays blocked on the+ // workspace-mutation auth ask recorded in the plan.+ case 'pup_move_to_workspace': {+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_move_to_workspace'); if (refusal) { sendJSON(res, refusal); return; } }+ let moveUrl = null;+ try { const at = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0]; moveUrl = at ? at.page.url() : session.page.url(); } catch (e) {}+ if (!moveUrl || /^(about:|chrome:|edge:|devtools:)/i.test(moveUrl)) {+ sendJSON(res, { success: false, errorCode: 'no_movable_url', error: `This window's active tab has no movable URL (${moveUrl || 'none'}).` });+ return;+ }+ let hd = null;+ try { hd = await chrome.abCommand('hd_api', { method: 'POST', path: '/open-url', body: { url: moveUrl } }, { timeoutMs: 12000, caller: sessionCaller(session) });+ } catch (e) { hd = { success: false, error: e.message }; }+ const hdOk = hd && hd.success !== false && !(hd.body && hd.body.error);+ if (!hdOk) {+ sendJSON(res, { success: false, errorCode: 'hydrogen_unreachable', error: `Hydrogen did not accept the URL: ${(hd && (hd.error || (hd.body && hd.body.error))) || 'unknown error'}`, _hint: 'Moving a pup page to the workspace needs Hydrogen (ah) running on this machine — check hd_status. The pup window was left untouched.' });+ return;+ }+ let windowClosed = false;+ if (args.keepWindow !== true) {+ try {+ const profileName = session.profileName;+ const _closingAppId = session._curAppId || `${PUP_APP_ID}.${resolvedId}`;+ for (const t of [...(session.tabs || [])]) { try { await t.page.close(); } catch (e) {} }+ session.tabs.length = 0; session.activeTabId = null;+ sessions.delete(resolvedId);+ deleteSessionFile(resolvedId);+ unregisterPupAumid(_closingAppId).catch(() => {});+ const be = browsers.get(profileName);+ if (be) { be.refCount = Math.max(0, be.refCount - 1); if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, profileName); } catch (e) {} browsers.delete(profileName); } }+ windowClosed = true;+ setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);+ } catch (e) {}+ }+ console.log(`[move-to-workspace] "${resolvedId}" -> Hydrogen (${moveUrl.slice(0, 80)}) — windowClosed=${windowClosed}`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, movedUrl: moveUrl, windowClosed, sessionId: resolvedId,+ _hint: windowClosed+ ? 'The page was handed to Hydrogen (its Browser Picker routes it — webview by default, honoring the user\'s per-domain choice) and this pup window closed. The user picks the surface if the picker prompts.'+ : 'The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true).',+ }) });+ return;+ } // v1.9.3 (AD #208): the taskbar jump-list callback — flip THIS wiki window between the // logged-in and public view IN PLACE. A view switch = a cookie-jar (userDataDir) switch, // fixed at browser launch, so it's a close-and-relaunch of the same sessionId under the@@ -12950,22 +13263,123 @@ const server = http.createServer(async (req, res) => { // v1.9.11: OS caption (AD >=1.9.144). Fire-and-forget: the old window is torn down // moments later, so an OS-level surface is the ONLY thing that survives the switch. wikiCaption(_switchMsg, 30000).catch(() => {});- // Close the current window (mirror pup_close_window) so we can relaunch under the- // other cookie jar. const oldProfile = session.profileName; const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;- for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }- session.tabs.length = 0; session.activeTabId = null;- sessions.delete(sessionId);- deleteSessionFile(sessionId);- unregisterPupAumid(_closingAppId).catch(() => {});- const be = browsers.get(oldProfile);- if (be) {- be.refCount = Math.max(0, be.refCount - 1);- if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }+ const newProfile = view === 'authed' ? DEFAULT_PROFILE : ('iso-' + sessionId + '-pub'); // v1.9.162: authed = the ONE shared jar (accumulate); public = a clean logged-out jar+ // v2.0.130 (#45, traced root cause): the close-first relaunch DROPPED THE FOREGROUND LOCK.+ // The moment the old window died, Windows handed foreground to the next app (the editor),+ // and by raise time SetForegroundWindow was denied — so a toggle clicked on a FOREGROUND+ // window came back in the background no matter how many raises we retried (2.0.57's+ // delayed second raise lost the same way). CREATE-THEN-SWAP instead: launch the+ // replacement under a temp id, restore all tabs, raise it WHILE the old window still+ // holds foreground (same-app focus handoff is permitted), THEN close the old window and+ // rename the temp session to the original id (owner, hwnd claim, files, jump list,+ // AUMID). Only when the original window was actually foreground AND this is a+ // USER_CALLERS click — AI-driven toggles keep background semantics. Bonus: the+ // zero-window dead-air gap during the switch is gone.+ let _wasForeground = false;+ if (process.platform === 'win32') {+ try {+ const fgH = await osGetForegroundWindow();+ const myH = await sessionHwnd(sessionId);+ _wasForeground = !!(fgH && myH && Number(fgH) === Number(myH));+ } catch (e) {}+ }+ const _userClickSwap = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+ if (_wasForeground && _userClickSwap) {+ const tempId = sessionId + '.swap';+ try {+ const sNew = await launchSession(tempId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true });+ for (let i = 0; i < _tabUrls.length; i++) {+ if (i === _activeIdx) continue;+ try { await addTabToSession(tempId, _tabUrls[i]); } catch (e) {}+ }+ try { const sT = sessions.get(tempId); const back = sT && sT.tabs && sT.tabs[0]; if (back) { sT.activeTabId = back.tabId; await back.page.bringToFront(); } } catch (e) {}+ // Raise the replacement WHILE we still own foreground via the old window.+ sNew._foreground = true;+ try { await osRaiseSessionWindow(sNew, tempId, 'you clicked the taskbar view switch — handing focus to the replacement window'); } catch (e) {}+ // Retire the old window (foreground stays with our replacement — same-app handoff).+ for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }+ session.tabs.length = 0; session.activeTabId = null;+ sessions.delete(sessionId);+ deleteSessionFile(sessionId);+ unregisterPupAumid(_closingAppId).catch(() => {});+ const beOld = browsers.get(oldProfile);+ if (beOld) {+ beOld.refCount = Math.max(0, beOld.refCount - 1);+ if (beOld.refCount <= 0) { try { await killBrowserProcess(beOld.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }+ }+ // RENAME temp -> original id: map, files, hwnd claim. The periodic brand sweep ++ // title injector key off the map id, so titles/badges restamp to the original id.+ sessions.delete(tempId);+ sessions.set(sessionId, sNew);+ try { deleteSessionFile(tempId); } catch (e) {}+ try { saveSessionFile(sessionId, newProfile, (() => { try { return sNew.page.url(); } catch (e) { return url; } })()); } catch (e) {}+ try { if (sNew._hwnd) persistSessionHwnd(sessionId, sNew._hwnd); } catch (e) {}+ sNew._wikiView = view;+ if (view === 'public') sNew._publicProfile = newProfile;+ sNew._wikiJumplistView = undefined;+ sNew._wikiJumplistKey = undefined;+ try { await osRaiseSessionWindow(sNew, sessionId, 'finishing the view switch you clicked (create-then-swap)'); } catch (e) {}+ sNew._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked switch to ${view} on a foreground window (create-then-swap)` };+ try { recordAgentUpdate(sNew, 'pup_wiki_set_view', { updateNote: `wiki view → ${view} (create-then-swap, foreground preserved)` }, sNew.activeTabId); } catch (e) {}+ try { flashViaAD(sessionId, {}); } catch (e) {}+ try { await setWikiModeGlyph(sNew.page, view === 'authed' ? '● Logged in' : '○ Public'); } catch (e) {}+ console.log(`[wikiview] "${sessionId}" -> ${view} via CREATE-THEN-SWAP (window was foreground; replacement raised before the old one closed)`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, view, url, foregrounded: true, swap: true,+ _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view. It was FOREGROUND when the user clicked, so pup used create-then-swap: the replacement was raised while the old window still held focus, then swapped in under the same id — the user never lost the window.`,+ }) });+ const s2 = sNew; // background cosmetics below expect s2+ (async () => {+ if (process.platform === 'win32') {+ await new Promise(r => setTimeout(r, 1500));+ try { await brandPupWindow(sessionId); } catch (e) {}+ try { await applyAppOverlay(s2, sessionId); } catch (e) {}+ try { await updateWikiJumplist(s2, sessionId); } catch (e) {}+ }+ if (view === 'authed') {+ await new Promise(r => setTimeout(r, 300));+ let a = null;+ try { a = await checkWikiAuth(s2.page); } catch (e) {}+ try { await setWikiModeGlyph(s2.page, (a && a.authed) ? '● Logged in' : '○ Public'); } catch (e) {}+ try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}+ const _doneMsg = a && a.authed+ ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`+ : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in — sign in in this window';+ wikiCaption(_doneMsg, 6000).catch(() => {});+ } else {+ try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}+ wikiCaption('\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view — what the world sees', 6000).catch(() => {});+ }+ })().catch(() => {});+ return;+ } catch (e) {+ console.log(`[wikiview] "${sessionId}" create-then-swap failed (${e.message}) — falling back to the legacy close-first relaunch`);+ // Clean up a half-launched temp session so the legacy path starts clean.+ try {+ const sT = sessions.get(tempId);+ if (sT) { for (const t of [...(sT.tabs || [])]) { try { await t.page.close(); } catch (e2) {} } sessions.delete(tempId); deleteSessionFile(tempId); }+ } catch (e2) {}+ // If the old window survived (launch failed before teardown), the legacy path below+ // re-does the close correctly; if it did not, the close loop no-ops.+ }+ }+ // Close the current window (mirror pup_close_window) so we can relaunch under the+ // other cookie jar.+ if (sessions.has(sessionId)) {+ for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }+ session.tabs.length = 0; session.activeTabId = null;+ sessions.delete(sessionId);+ deleteSessionFile(sessionId);+ unregisterPupAumid(_closingAppId).catch(() => {});+ const be = browsers.get(oldProfile);+ if (be) {+ be.refCount = Math.max(0, be.refCount - 1);+ if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }+ } } // Relaunch the SAME sessionId under the new profile at the same URL.- const newProfile = view === 'authed' ? DEFAULT_PROFILE : ('iso-' + sessionId + '-pub'); // v1.9.162: authed = the ONE shared jar (accumulate); public = a clean logged-out jar let s2 = null; try { s2 = await launchSession(sessionId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true }); } catch (e) { sendJSON(res, { success: false, error: `wiki view relaunch failed: ${e.message}` }); return; }