const $ = (id) => document.getElementById(id);
/*
* Where this page is mounted, derived rather than configured. "/browser/" gives "/browser";
* "/" gives "". Everything below routes through here, so the console works identically on its
* own host and as a page inside LeadFlow.
*/
const BASE = location.pathname.replace(/\/[^/]*$/, '').replace(/\/+$/, '');
const mounted = (path) => BASE + path;
/*
* THE HANDOFF FROM LEADFLOW, read before anything else happens.
*
* It carries two separate things: WHO you are (so the console does not ask you to sign in a second
* time inside an app you are already inside), and optionally WHICH search to fill.
*
* Parsed at the very top because the login gate is drawn early, and a sign-on that arrives after
* the form has been shown has failed at the only job it has. This was exactly that bug: the
* handoff was read near the bottom of the file and the gate at line 593 had already run.
*
* A FRAGMENT rather than a query string: it never reaches a server log or a Referer header. It is
* wiped from the address bar immediately, so a reload or a shared screenshot carries no credential.
*/
let leadflow = null;
let ssoToken = null;
let social = null;
/* LeadFlow toggles a class on its own root; an iframe cannot see that, so the theme is handed over
and then pushed on every change. Opened on its own, the media query decides. */
function applyTheme(t) {
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
else document.documentElement.removeAttribute('data-theme');
}
addEventListener('message', (e) => {
// Same origin only. This is a page inside another app, not a public embed.
if (e.origin !== location.origin) return;
if (e.data && e.data.type === 'theme') applyTheme(e.data.theme);
});
(function readHandoff() {
const m = (location.hash || '').match(/[#&]leadflow=([^&]+)/);
if (!m) return;
let payload;
try { payload = JSON.parse(decodeURIComponent(m[1])); } catch { return; }
history.replaceState(null, '', location.pathname + location.search);
// The identity half stands alone: opening the Browser tab hands one over without starting a
// search at all, which is the ordinary case.
if (payload.auth) ssoToken = payload.auth;
if (payload.theme) applyTheme(payload.theme);
/* Embedded: this is a page of LeadFlow, not a product of its own. Its brand would be a second
name for something already titled "Browser" one line above, and its Sign out means something
different from the one in the app's header — which is exactly the kind of small wrongness that
makes an integration feel bolted on. */
if (payload.auth) document.documentElement.setAttribute('data-embedded', 'true');
leadflow = (payload.api && payload.searchId && payload.token) ? payload : null;
// The conversation endpoint arrives on its own: opening the Browser tab to check for answers
// hands one over with no search attached at all, which is the ordinary case after the first day.
social = (payload.socialApi && payload.socialToken)
? { api: payload.socialApi, token: payload.socialToken } : null;
})();
let session = null, ws = null, activeProfile = '', framesSeen = 0, frameErr = false;
const log = (m, cls='') => {
const d = document.createElement('div');
if (cls) d.className = cls;
d.textContent = new Date().toLocaleTimeString() + ' ' + m;
$('log').prepend(d);
return d;
};
/* A warning you can act on. Closing a browser tab does not close the session behind it, so
"already open in another session" points at something the user cannot see and cannot reach —
which is a dead end unless the way out is right there on the message. */
const logAction = (m, label, fn) => {
const d = log(m, 'err');
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'margin-left:8px;padding:2px 8px;font-size:11px;border-radius:5px;'
+ 'border:1px solid var(--accent);background:transparent;color:var(--accent);cursor:pointer';
b.onclick = async () => { b.disabled = true; b.textContent = 'working…'; await fn(); b.remove(); };
d.appendChild(b);
};
async function api(path, opts = {}) {
// mounted(), NOT at(). There is a pointer-event helper further down also called at(), and it is a
// hoisted function declaration — so it won, silently, and every single API call went through it
// and produced a nonsense URL. The console rendered, the first call 404ed, gateState threw before
// it could try single sign-on, and the login form was left on screen looking like SSO had failed.
// One missed rename, two symptoms that looked unrelated.
const r = await fetch(mounted(path), { ...opts, headers:{ 'Content-Type':'application/json', ...(opts.headers||{}) } });
const b = await r.json().catch(() => ({}));
// The body of a refusal often carries the way out; throwing only the message discards it.
if (!r.ok) throw Object.assign(new Error(b.error || ('HTTP ' + r.status)), { body: b, status: r.status });
return b;
}
// ── Gate ──────────────────────────────────────────────────────────────
let mode = 'login', minPw = 10;
/* Live, so the button cannot be pressed against a rule that will reject it. */
function checkPw() {
if (mode !== 'signup') { $('gateGo').disabled = false; $('gateMsg').textContent = ''; return; }
const n = $('p').value.length;
const ok = n >= minPw && $('u').value.trim().length >= 3;
$('gateGo').disabled = !ok;
$('gateMsg').className = 'msg';
$('gateMsg').textContent = n === 0 ? '' : (n < minPw ? (minPw - n) + ' more character' + (minPw - n === 1 ? '' : 's') : 'looks good');
if (n >= minPw) $('gateMsg').className = 'msg ok';
}
async function gateState() {
const s = await api('/api/auth/state');
/* Who you are, answered once, where the console already asks it — see the dashboard's Admin nav. */
window.__ghostSuper = !!s.superadmin;
if (s.signedIn) return enter();
/*
* Signed into LeadFlow is signed in here. Tried BEFORE the gate is drawn, so nobody ever sees a
* second login inside an app they are already inside — which is the whole reason this exists.
*
* A failure here is not fatal and is not silent: the password gate is still there, and the reason
* is shown, because "expired LeadFlow session" and "SSO is not configured" have different fixes.
*/
if (ssoToken && s.sso) {
try {
await api('/api/auth/sso', { method:'POST', body: JSON.stringify({ token: ssoToken }) });
ssoToken = null;
return enter();
} catch (e) {
$('gateMsg').textContent = e.message;
$('gateMsg').className = 'msg bad';
}
}
/*
* SSO-ONLY (platform-managed install): there is NO local password account — the only way in is the
* handoff from the platform. Hide the username/password gate entirely so nobody can create an owner
* here; tell them where to sign in instead. (Any failed-SSO reason set above stays in gateMsg.)
*/
/*
* THE FORM STAYS. An SSO-only instance used to hide it, and a hand-off that failed left a page
* with nothing to press. The owner's email and the password they set here always work; when no
* password has been set yet the login says so, in words, instead of a blank card.
*/
if (s.ssoOnly) {
mode = 'login';
$('gateSub').textContent = s.hasPassword
? 'Open Ghost Browser from your platform (the Tools tab), or sign in with your email and the password you set here.'
: 'Open Ghost Browser from your platform (the Tools tab). To sign in here directly, set a password in Settings after that first sign-in.';
$('gateGo').textContent = 'Sign in';
$('p').placeholder = 'The password you set on this Ghost Browser';
checkPw();
return;
}
mode = s.needsSignup ? 'signup' : 'login';
minPw = s.minPassword || 10;
$('gateSub').textContent = mode === 'signup'
? 'No account yet. The first one you create is the owner — after this there is only a login.'
: 'Sign in to drive the browser.';
$('gateGo').textContent = mode === 'signup' ? 'Create the owner account' : 'Sign in';
// Say the rule BEFORE it is broken. Twice the signup was rejected for a short password and the
// account was never created, which then reads as "my login does not work".
$('p').placeholder = mode === 'signup' ? ('Password — at least ' + minPw + ' characters') : 'Password';
checkPw();
}
$('gateGo').onclick = async () => {
$('gateMsg').className = 'msg'; $('gateMsg').textContent = '';
try {
await api('/api/auth/' + mode, { method:'POST', body: JSON.stringify({ username:$('u').value, password:$('p').value }) });
enter();
} catch (e) { $('gateMsg').className = 'msg bad'; $('gateMsg').textContent = e.message; }
};
$('p').onkeydown = (e) => { if (e.key === 'Enter') $('gateGo').click(); };
$('p').oninput = checkPw;
$('u').oninput = checkPw;
$('forgot').onclick = (e) => { e.preventDefault(); $('resetBox').classList.toggle('hide'); };
$('resetGo').onclick = async () => {
$('gateMsg').className = 'msg';
try {
await api('/api/auth/reset', { method:'POST', headers:{ Authorization: 'Bearer ' + $('resetKey').value.trim() } });
$('gateMsg').className = 'msg ok';
$('gateMsg').textContent = 'Account cleared — create a new one above.';
$('resetBox').classList.add('hide');
// ── Where this browser exits ──────────────────────────────────────────────
$('exitBtn').onclick = () => {
const p = $('exitPanel');
p.classList.toggle('hide');
if (!p.classList.contains('hide')) { tsRefresh(); p.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
};
// The header is the shortest route to the thing it is warning about.
/*
* THE HEADER IS THE SHORTEST ROUTE TO THE THING IT IS WARNING ABOUT.
*
* It reported "exit: this server" in amber, was styled as clickable, and clicking it did nothing —
* twice over. It went through $('exitBtn').click() rather than opening the panel itself, and the
* panel opens between the header and the toolbar, which on a scrolled tablet is above the visible
* area. So on the occasions the indirection did fire, the result was still off-screen: identical
* to broken, from the only seat that matters.
*/
$('exitState').onclick = () => {
const p = $('exitPanel');
p.classList.remove('hide');
tsRefresh();
// Opening something the reader cannot see is the same as not opening it.
p.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
async function tsRefresh() {
try {
const t = await api('/v1/tailscale/status');
try { $('tsAll').checked = (await api('/v1/agent/settings')).routeThroughTailnet !== false; } catch {}
if (!t.installed) { $('tsState').textContent = 'not available in this image'; return; }
$('tsState').textContent = !t.running ? 'not running'
: t.loggedIn ? ('connected as ' + (t.self?.name || '?') + (t.exitNode ? ' → exiting via ' + t.exitNode : ' · no exit node chosen'))
: (t.backendState || 'starting…');
if (t.loginUrl && !t.loggedIn) {
$('tsLogin').classList.remove('hide');
$('tsLogin').innerHTML = 'Open this to authorise, then press Connect again: '
+ '' + t.loginUrl + '';
} else { $('tsLogin').classList.add('hide'); }
const nodes = t.exitNodes || [];
if (!t.loggedIn) { $('tsNodes').innerHTML = ''; return; }
if (!nodes.length) {
/* Say WHY the list is empty rather than showing nothing: the setting they are missing is on
the phone, not here, and it is off by default. */
const others = (t.devices || []).filter(d => !d.canExit).map(d => d.name + (d.online ? '' : ' (offline)')).join(', ');
$('tsNodes').innerHTML = 'No device is offering itself as an exit node'
+ (others ? '. Seen on your tailnet: ' + others : '') + '';
return;
}
$('tsNodes').innerHTML = '';
for (const n of nodes) {
const b = document.createElement('button');
b.textContent = (n.inUse ? '● ' : '') + n.name + (n.os ? ' (' + n.os + ')' : '') + (n.online ? '' : ' — offline');
b.disabled = !n.online;
b.style.cssText = 'margin:0 6px 6px 0;font-size:12px;' + (n.inUse ? 'border-color:var(--ok);color:var(--ok)' : '');
b.onclick = async () => {
try { await api('/v1/tailscale/exit-node', { method:'POST', body: JSON.stringify({ node: n.name }) });
log('exiting through ' + n.name, 'ok'); tsRefresh(); }
catch (e) { log(e.message, 'err'); }
};
$('tsNodes').appendChild(b);
}
const off = document.createElement('button');
off.textContent = 'no exit node';
off.style.cssText = 'margin:0 6px 6px 0;font-size:12px';
off.onclick = async () => { await api('/v1/tailscale/exit-node', { method:'POST', body: JSON.stringify({ node: '' }) }); tsRefresh(); };
$('tsNodes').appendChild(off);
} catch (e) { $('tsState').textContent = e.message; }
}
$('tsUp').onclick = async () => {
$('tsState').textContent = 'connecting…';
try {
const r = await api('/v1/tailscale/up', { method:'POST', body: JSON.stringify({ authKey: $('tsKey').value.trim() || undefined }) });
$('tsKey').value = ''; // used once, never stored
if (r.loginUrl && !r.loggedIn) log('open the Tailscale link to authorise this browser');
tsRefresh();
exitState();
} catch (e) { log(e.message, 'err'); $('tsState').textContent = 'failed'; }
};
$('tsAll').onchange = async () => {
const on = $('tsAll').checked;
try {
await api('/v1/agent/settings', { method:'PUT', body: JSON.stringify({ routeThroughTailnet: on }) });
// Fixed when a browser process starts, so it lands on the next session and not this one.
log(on ? 'every login now leaves through the exit node — reopen a session for it to apply'
: 'logins now leave from this server unless one says otherwise', 'ok');
exitState();
} catch (e) { $('tsAll').checked = !on; log(e.message, 'err'); }
};
$('tsDown').onclick = async () => { try { await api('/v1/tailscale/down', { method:'POST' }); tsRefresh(); } catch (e) { log(e.message,'err'); } };
gateState();
} catch (err) { $('gateMsg').className = 'msg bad'; $('gateMsg').textContent = err.message; }
};
function enter() {
$('gate').classList.add('hide'); $('dash').classList.remove('hide');
capacity(); loadProfiles(); setInterval(capacity, 5000);
// The agent panel needs a session, and this is the first moment there is one.
loadPanel();
// Open it unprompted the first time there is no exit configured — a setting nobody finds is a
// setting nobody uses, and this one decides whether logins work at all.
api('/v1/tailscale/status').then((t) => {
if (!t.loggedIn || !t.exitNode) { $('exitPanel').classList.remove('hide'); tsRefresh(); }
}).catch(() => {});
}
$('logout').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); location.reload(); };
/* Set the password that opens this instance without the platform (Settings → Account). */
(function () {
const go = $('setPwGo'), inp = $('setPw'), msg = $('setPwMsg');
if (!go || !inp) return;
go.onclick = async () => {
msg.className = 'smsg'; msg.textContent = '';
try {
await api('/api/auth/password', { method: 'POST', body: JSON.stringify({ password: inp.value }) });
inp.value = ''; msg.className = 'smsg ok'; msg.textContent = 'Set. You can now sign in here with your email and this password, on any device.';
} catch (e) { msg.className = 'smsg bad'; msg.textContent = e.message; }
};
})();
$('showKey').onclick = async () => {
try {
const r = await api('/api/auth/key');
/*
* GO TO WHERE THE ANSWER IS. #keyBox lives inside the Log section, which is `hidden` unless the
* Log tab is open — so this un-hid a box nobody could see and the button did nothing at all
* from the Agent tab, which is the one everybody is on. A control that reveals something must
* take you to it.
*/
showTab('log');
$('keyBox').classList.remove('hide');
$('keyBox').textContent = r.keys.map(k => k.key + ' (' + k.plan + ')').join('\n') || 'no keys configured';
} catch (e) { log(e.message, 'err'); }
};
async function capacity() {
try {
const c = await (await fetch(mounted('/v1/capacity'))).json();
$('cap').textContent = c.sessions + '/' + c.maxSessions + ' sessions · ' + c.memoryPct + '% memory' + (c.draining ? ' · draining' : '');
} catch { $('cap').textContent = 'unreachable'; }
exitState();
}
let PRESETS = [], SINGLE = false, BROWSER_PROFILE = '';
async function loadProfiles() {
try {
/*
* ONE BROWSER. There is nothing to pick and nothing to switch — a single browser signed into
* every site. The picker collapses to a static "Browser", and the setting-up, throwaway and
* close-all controls that only made sense with many logins are gone.
*/
try {
const sb = await api('/v1/agent/settings');
if (sb.singleBrowser) {
SINGLE = true;
BROWSER_PROFILE = sb.browserProfile || 'facebook';
$('profile').innerHTML = '';
$('profile').value = BROWSER_PROFILE;
document.documentElement.setAttribute('data-single', 'true');
return;
}
} catch { /* fall through to the many-logins UI */ }
const r = await (await fetch(mounted('/v1/profiles'))).json();
const saved = r.profiles || [];
try { PRESETS = (await api('/v1/profiles/presets')).presets || []; } catch { PRESETS = []; }
/*
* The logins you have, then the ones this browser knows how to set up. Choosing one of those
* creates it already labelled with its site — which is what the agent matches on, and what was
* previously typed by hand and therefore silently wrong.
*/
const missing = PRESETS.filter(x => !x.exists);
/* A login that already serves a site says so beside its name — "facebook.com" on a profile
called carla-test-facebook is the answer to "is my Facebook set up", and it was previously
only visible by opening Setup. */
const serves = {};
for (const x of PRESETS) if (x.servedBy) serves[x.servedBy] = x.site;
$('profile').innerHTML = saved.map(p =>
'')
.concat(missing.length
? ['']
: [])
.concat(['',
'']).join('');
/*
* A SAVED LOGIN IS THE DEFAULT, not a throwaway.
*
* Asking it to "open my stored Facebook session" and watching it open an empty browser is
* exactly backwards: the throwaway is the rare case, and the logins took real work to get. The
* one used last is remembered, because whoever has two profiles is usually going back to the
* same one.
*/
const last = localStorage.getItem('gb_profile');
if (last && saved.includes(last)) $('profile').value = last;
else if (saved.length) $('profile').value = saved[0];
pointUrlAtProfile(); // land the bar on the remembered login, not the hard-coded default
} catch {}
}
$('profile').onchange = () => {
if ($('profile').value !== '__new__') return;
const n = prompt('Name this profile (e.g. facebook-carla). Whatever you log into stays logged in.');
if (!n) { $('profile').value = ''; return; }
const c = n.replace(/[^a-z0-9_-]/gi,'');
$('profile').insertAdjacentHTML('afterbegin', '');
};
// ── The live view ─────────────────────────────────────────────────────
/* Frames arrive over a WebSocket whenever the page actually changes, and are drawn to a canvas.
Input goes back the same way as real mouse and key events, so the page behaves the way it would
in any tab — no side panel, no "click a field first", no polling. */
const view = $('view');
const ctx = view.getContext('2d', { alpha:false });
function connectLive() {
framesSeen = 0; frameErr = false;
if (ws) { try { ws.close(); } catch {} ws = null; }
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + mounted('/v1/live') + '?session=' + encodeURIComponent(session));
ws.onopen = () => { $('wsdot').classList.add('on'); log('live view connected', 'ok'); };
ws.onclose = () => { $('wsdot').classList.remove('on'); };
ws.onerror = () => log('live view dropped', 'err');
ws.onmessage = async (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.t === 'frame') {
try {
/* Decode base64 -> Blob directly. fetch('data:...') is governed by CSP connect-src and
can fail silently in a sandboxed/proxied frame, leaving the canvas black on every profile. */
const bin = atob(m.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
if (view.width !== bmp.width || view.height !== bmp.height) { view.width = bmp.width; view.height = bmp.height; }
ctx.drawImage(bmp, 0, 0);
bmp.close();
if (!framesSeen++) log('live view painting (' + view.width + 'x' + view.height + ')', 'ok');
view.hidden = false; $('empty').classList.add('hide'); $('hint').hidden = false;
} catch (e) { if (!frameErr) { frameErr = true; log('live frame draw failed: ' + (e && e.message || e), 'err'); } }
} else if (m.t === 'url') { $('url').value = m.url; }
else if (m.t === 'focus') {
/* The tap landed on a real field → raise the keyboard (still inside the tap's activation).
It landed on anything else → put the keyboard away rather than leave it covering the page. */
if (m.editable) { try { kbd.focus({ preventScroll: true }); } catch {} setKbdBtn(true); }
else if (document.activeElement === kbd) { kbd.blur(); setKbdBtn(false); }
}
else if (m.t === 'error') { log(m.error, 'err'); }
};
}
/* The canvas is displayed scaled; every coordinate is mapped back to page pixels or every click
lands somewhere else, which looks exactly like the automation being broken. */
function at(ev) {
const r = view.getBoundingClientRect();
return {
x: Math.round((ev.clientX - r.left) * (view.width / r.width)),
y: Math.round((ev.clientY - r.top) * (view.height / r.height)),
};
}
const mods = (e) => ({ alt:e.altKey, ctrl:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
const send = (o) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); };
view.addEventListener('mousedown', (e) => {
e.preventDefault(); view.focus();
// The keyboard is raised by the server's 'focus' reply now, not blindly on every press.
const p = at(e);
send({ t:'mouse', type:'mousePressed', x:p.x, y:p.y, button: e.button === 2 ? 'right' : 'left', clickCount: e.detail || 1, mod: mods(e) });
});
view.addEventListener('mouseup', (e) => {
e.preventDefault();
const p = at(e);
send({ t:'mouse', type:'mouseReleased', x:p.x, y:p.y, button: e.button === 2 ? 'right' : 'left', clickCount: e.detail || 1, mod: mods(e) });
});
let lastMove = 0;
view.addEventListener('mousemove', (e) => {
// Hover matters (menus, tooltips) but every pixel does not. ~30/s is smooth and cheap.
const now = performance.now();
if (now - lastMove < 33) return;
lastMove = now;
const p = at(e);
send({ t:'mouse', type:'mouseMoved', x:p.x, y:p.y, mod: mods(e) });
});
view.addEventListener('wheel', (e) => {
e.preventDefault();
const p = at(e);
send({ t:'wheel', x:p.x, y:p.y, dx:e.deltaX, dy:e.deltaY, mod: mods(e) });
}, { passive:false });
view.addEventListener('contextmenu', (e) => e.preventDefault());
/* Keys only while the canvas has focus, so the address bar still behaves normally. Tab is
swallowed deliberately: inside a page it should move between the page's fields. */
view.addEventListener('keydown', (e) => {
if (!['F5','F12'].includes(e.key)) e.preventDefault();
send({ t:'key', type:'keyDown', key:e.key, code:e.code, mod:mods(e) });
});
view.addEventListener('keyup', (e) => {
e.preventDefault();
send({ t:'key', type:'keyUp', key:e.key, code:e.code, mod:mods(e) });
});
view.addEventListener('paste', (e) => {
e.preventDefault();
send({ t:'paste', text: (e.clipboardData || window.clipboardData).getData('text') });
});
/* ── MOBILE ────────────────────────────────────────────────────────────────────────────
Two things a phone needs that a desktop does not.
THE KEYBOARD. Focusing the hidden input has to happen inside the touch handler itself: browsers
only raise the keyboard in response to a user gesture, and a focus() called from a promise
callback a moment later is silently ignored.
TOUCH. There is no mousedown on a phone. A tap becomes press+release at that point; a drag
becomes a wheel, because dragging a page is how scrolling works and nobody expects it to select
text. */
const kbd = $('kbd');
const ZWSP = '\u200b';
/* The punctuation an address or a password actually contains. Anything not listed sends no code
at all, which is fine — the character itself is what inserts it. */
const PUNCT_CODE = {
'.':'Period', ',':'Comma', '-':'Minus', '_':'Minus', '@':'Digit2', '/':'Slash', ':':'Semicolon',
';':'Semicolon', "'":'Quote', '"':'Quote', '?':'Slash', '!':'Digit1', ' ':'Space',
'+':'Equal', '=':'Equal', '(':'Digit9', ')':'Digit0', '#':'Digit3', '$':'Digit4', '%':'Digit5',
'&':'Digit7', '*':'Digit8', '[':'BracketLeft', ']':'BracketRight',
};
const resetKbd = () => { kbd.value = ZWSP; };
kbd.addEventListener('input', () => {
const v = kbd.value;
if (v.length > ZWSP.length) {
// Whatever was added since the last reset is what they typed.
const typed = v.replace(ZWSP, '');
for (const ch of typed) {
/* 'Key' + the character is only a real code for letters. For '.' it invents "Key." — the
server no longer trusts it, but sending nonsense is still worse than sending nothing. */
const code = /[a-zA-Z]/.test(ch) ? 'Key' + ch.toUpperCase()
: /[0-9]/.test(ch) ? 'Digit' + ch
: (PUNCT_CODE[ch] || '');
send({ t:'key', type:'keyDown', key:ch, code });
send({ t:'key', type:'keyUp', key:ch, code });
}
} else if (v.length < ZWSP.length) {
// The sentinel is gone, so they pressed Backspace — the one key Android will not report.
send({ t:'key', type:'keyDown', key:'Backspace', code:'Backspace' });
send({ t:'key', type:'keyUp', key:'Backspace', code:'Backspace' });
}
resetKbd();
});
kbd.addEventListener('keydown', (e) => {
// Everything that is not a character still comes through as a real key event.
if (e.key.length === 1) return; // handled by the input event above
if (e.key === 'Backspace') return; // handled there too, more reliably
e.preventDefault();
send({ t:'key', type:'keyDown', key:e.key, code:e.code, mod:mods(e) });
send({ t:'key', type:'keyUp', key:e.key, code:e.code, mod:mods(e) });
});
const touchAt = (t) => {
const r = view.getBoundingClientRect();
return {
x: Math.round((t.clientX - r.left) * (view.width / r.width)),
y: Math.round((t.clientY - r.top) * (view.height / r.height)),
};
};
let touchStart = null, touchMoved = false;
view.addEventListener('touchstart', (e) => {
if (!e.touches.length) return;
/*
* The keyboard is NO LONGER raised here. Doing it on every touch meant it covered the page each
* time you scrolled or tapped a link. Instead the server says, right after a tap lands, whether
* the tap focused a real field — see the 'focus' message below — and only then is the keyboard
* raised, while this tap's activation is still live.
*/
touchStart = touchAt(e.touches[0]);
touchMoved = false;
}, { passive:true });
view.addEventListener('touchmove', (e) => {
if (!touchStart || !e.touches.length) return;
const p = touchAt(e.touches[0]);
const dy = touchStart.y - p.y;
const dx = touchStart.x - p.x;
if (Math.abs(dy) > 6 || Math.abs(dx) > 6) {
e.preventDefault();
touchMoved = true;
send({ t:'wheel', x:p.x, y:p.y, dx, dy });
touchStart = p;
}
}, { passive:false });
view.addEventListener('touchend', (e) => {
if (!touchStart) return;
if (!touchMoved) {
// A tap, not a drag: press and release where the finger went down.
send({ t:'mouse', type:'mousePressed', x:touchStart.x, y:touchStart.y, button:'left', clickCount:1 });
send({ t:'mouse', type:'mouseReleased', x:touchStart.x, y:touchStart.y, button:'left', clickCount:1 });
}
touchStart = null;
}, { passive:true });
resetKbd();
// ── Sessions ──────────────────────────────────────────────────────────
async function openSession(takeover = false) {
const picked = $('profile').value;
// A preset names a site rather than a profile: the server creates it, labels it, and configures
// it before the browser starts — which is the only moment those settings can take effect.
if (picked.startsWith('preset:')) {
return api('/v1/sessions', { method:'POST', body: JSON.stringify({ preset: picked.slice(7), takeover }) });
}
const profile = picked === '__new__' ? '' : picked;
return api('/v1/sessions', { method:'POST', body: JSON.stringify({ reuse: !profile, profile: profile || undefined, takeover }) });
}
$('open').onclick = async () => {
try {
await adopt(await openSession(false));
} catch (e) {
if (e.body && e.body.canTakeover) {
logAction(e.message, 'Take it over', async () => {
try { const s = await openSession(true); await adopt(s); }
catch (err) { log(err.message, 'err'); }
});
} else { log(e.message, 'err'); }
}
};
/* Shared by a normal open and a takeover, so the two paths cannot drift. */
async function adopt(s) {
session = s.sessionId;
// The dropdown IS the active session now, so whatever actually opened is what it shows.
activeProfile = s.profile || '';
if (s.profile && [...$('profile').options].some((o) => o.value === s.profile)) $('profile').value = s.profile;
// So the next visit opens the same login rather than starting from a throwaway again.
if (s.profile) { try { localStorage.setItem('gb_profile', s.profile); } catch {} }
['go','close','marks'].forEach(id => $(id).disabled = false);
log('session ' + session + (s.profile ? ' · profile "' + s.profile + '"' : ''), 'ok');
connectLive();
/* A preset opens on its own login page and says what is waiting there — a code, a captcha, a
consent wall. Somebody setting up a login is about to meet one of those, and being told which
is the difference between a minute and an evening. */
const preset = PRESETS.find(x => x.profile === s.profile);
if (preset) {
$('url').value = preset.start;
await api('/v1/sessions/' + session + '/navigate', { method:'POST', body: JSON.stringify({ url: preset.start }) }).catch(() => {});
log(preset.label + ': ' + preset.hint, 'ok');
await loadProfiles();
$('profile').value = s.profile;
loadSiteRow();
return;
}
if ((!s.url || s.url === 'about:blank') && $('url').value.trim()) {
await api('/v1/sessions/' + session + '/navigate', { method:'POST', body: JSON.stringify({ url: $('url').value }) });
}
setTimeout(() => view.focus(), 500);
}
$('close').onclick = async () => {
try { if (ws) ws.close(); await api('/v1/sessions/' + session, { method:'DELETE' }); log('closed', 'ok'); }
catch (e) { log(e.message, 'err'); }
session = null; activeProfile = ''; view.hidden = true; $('empty').classList.remove('hide'); $('hint').hidden = true;
['go','close','marks','esc'].forEach(id => $(id).disabled = true);
loadProfiles();
};
$('closeAll').onclick = async () => {
try { if (ws) ws.close(); const r = await api('/v1/sessions', { method:'DELETE' }); log('closed ' + r.closed + ' session(s)', 'ok'); }
catch (e) { log(e.message, 'err'); }
session = null; activeProfile = ''; view.hidden = true; $('empty').classList.remove('hide');
['go','close','marks','esc'].forEach(id => $(id).disabled = true);
};
/* Escape, without needing the canvas to have focus first. A page waiting on a passkey blocks its
own fallback button, and the way out was "click the canvas, then press Esc" — which does not help
when the thing you cannot do is click the canvas. */
$('esc').onclick = () => {
send({ t:'key', type:'keyDown', key:'Escape', code:'Escape' });
send({ t:'key', type:'keyUp', key:'Escape', code:'Escape' });
log('sent Escape');
view.focus();
};
/* Per profile, and off by default: refusing WebAuthn is right for a container with no fingerprint
reader and wrong for a site that genuinely needs one. */
$('noPasskey').onchange = async () => {
const profile = $('profile').value;
if (!profile || profile === '__new__') { log('pick a named profile first', 'err'); $('noPasskey').checked = false; return; }
const box = $('noPasskey');
box.disabled = true;
try {
// The server reloads any open session on this profile, so this call is not instant — saying so
// beats a checkbox that looks like it did nothing for three seconds.
log(box.checked ? 'refusing passkeys and reloading the page…' : 'saving…');
const r = await api('/v1/profiles/' + encodeURIComponent(profile) + '/settings', {
method:'PUT', body: JSON.stringify({ blockPasskeys: box.checked }),
});
log(r.note || 'saved', 'ok');
} catch (e) { log(e.message, 'err'); box.checked = !box.checked; }
finally { box.disabled = false; }
};
/*
* THE ADDRESS BAR FOLLOWS THE LOGIN YOU PICK.
*
* Choosing "linkdin — linkedin.com" and pressing Open while the bar still read facebook.com opened
* Facebook's login page: the profile was right and the destination was left behind, because Open
* navigates to whatever is in the bar unless the profile's NAME happens to equal a preset's — and
* "linkdin" is not "linkedin". So the moment a login is chosen, the bar points at that login's own
* site, and Open lands where the name says it will.
*/
async function pointUrlAtProfile(alsoRole = false) {
const v = $('profile').value;
if (!v || v === '__new__') return; // a throwaway or an unnamed new one: leave the bar
if (v.startsWith('preset:')) {
const preset = PRESETS.find((x) => x.key === v.slice(7));
if (preset && preset.start) $('url').value = preset.start;
return;
}
// A saved login: aim at its site. A preset for that site knows the login page; otherwise the
// site's own home — which for a logged-in profile lands on the feed, and for one that is not yet
// logged in shows the sign-in, which is exactly the invitation to finish setting it up.
try {
const cfg = await api('/v1/profiles/' + encodeURIComponent(v) + '/settings');
let site = String(cfg && cfg.site || '').replace(/^www\./, '');
/*
* A login with no site label — like "linkdin", which has visited LinkedIn but is not signed in
* yet — still has a name that says where it belongs. Guessing from the name only chooses a
* SUGGESTED address the person can still edit; it changes nothing the agent sees, so a profile
* that is not really logged in is not advertised to the agent as though it were.
*/
if (!site) {
const guess = [
[/facebook|meta|(^|[^a-z])fb([^a-z]|$)/, 'facebook.com'],
[/link.?d.?in/, 'linkedin.com'],
[/google/, 'google.com'],
[/youtube|(^|[^a-z])yt([^a-z]|$)/, 'youtube.com'],
[/insta|(^|[^a-z])ig([^a-z]|$)/, 'instagram.com'],
].find(([re]) => re.test(v.toLowerCase()));
if (guess) site = guess[1]; else return;
}
const preset = PRESETS.find((x) => x.site === site);
$('url').value = preset ? preset.start : 'https://www.' + site + '/';
if (alsoRole) setRoleForSite(site);
} catch { /* the bar simply keeps whatever it had */ }
}
/*
* THE ROLE FOLLOWS THE LOGIN, the same way the address bar does.
*
* Choosing the LinkedIn login while the role still read "Facebook · Lead scout" is the same
* confusion as the address bar was — the browser went to LinkedIn and the specialist stayed on
* Facebook. So picking a login moves the role to that site's scout, UNLESS a role for that same
* site is already chosen, so a deliberate "Conversations" on the right site is not reset to "scout".
*/
function setRoleForSite(site) {
const prefix = String(site || '').split('.')[0]; // linkedin.com -> linkedin
if (!prefix) return;
const sel = $('roleSel');
const cur = sel.value || '';
if (cur.split('.')[0] === prefix) return; // already this site's craft — keep the exact one
const opts = Array.from(sel.options);
const want = opts.find((o) => o.value === prefix + '.scout') // the reading role, by preference
|| opts.find((o) => o.value.startsWith(prefix + '.')); // otherwise the site's first role
if (!want) return; // no role for this site (e.g. YouTube) — leave it
sel.value = want.value;
try { localStorage.setItem('gb_role', want.value); } catch {}
showRole();
gripStatus();
}
$('profile').addEventListener('change', () => { loadSiteRow(); exitState(); pointUrlAtProfile(true); });
$('profile').addEventListener('change', async () => {
const profile = $('profile').value;
if (!profile || profile === '__new__') { $('noPasskey').checked = false; return; }
try { const cfg = await api('/v1/profiles/' + encodeURIComponent(profile) + '/settings'); $('noPasskey').checked = !!cfg.blockPasskeys; }
catch { $('noPasskey').checked = false; }
});
$('go').onclick = async () => {
try {
const r = await api('/v1/sessions/' + session + '/navigate', { method:'POST', body: JSON.stringify({ url: $('url').value }) });
log('→ ' + r.url + ' (' + r.status + ')');
view.focus();
} catch (e) { log(e.message, 'err'); }
};
$('url').onkeydown = (e) => { if (e.key === 'Enter') $('go').click(); };
/* What the AGENT can see on this same page. An empty list means the agent cannot act here and a
human has to — which is the entire reason this console exists. */
$('marks').onclick = async () => {
try {
const a = await api('/v1/sessions/' + session + '/analyze?screenshot=false');
$('els').innerHTML = '';
for (const el of a.elements) {
const li = document.createElement('li');
li.innerHTML = '' + el.index + '';
li.querySelector('span').textContent = el.text || el.placeholder || el.ariaLabel || el.tag;
li.onclick = () => api('/v1/sessions/' + session + '/click', { method:'POST', body: JSON.stringify({ index: el.index }) })
.then(r => log('click [' + el.index + '] ' + (r.clicked.text || '')))
.catch(e => log(e.message, 'err'));
$('els').appendChild(li);
}
log(a.elementCount + ' elements · ' + a.title + (a.elementCount ? '' : ' — nothing the agent can click here'));
} catch (e) { log(e.message, 'err'); }
};
gateState();
// == THE AGENT ==========================================================================
//
// The panel is a VIEW OF THE JOB RECORD, not a second copy of the story. Every step, lead and
// proposal shown here came off the socket that the server emits from the record itself, and a
// reconnect replays the whole thing - so a dropped phone connection mid-run loses nothing but the
// seconds it was gone.
let job = null, ajws = null, companies = [], cfg = {};
// ---- tabs -----------------------------------------------------------------------------
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
document.querySelectorAll('.tabs button').forEach(x => x.classList.toggle('on', x === b));
['agent','els','log'].forEach(t => $('tab-' + t).hidden = (t !== b.dataset.tab));
if (b.dataset.tab === 'agent') { $('askBadge').classList.add('hide'); }
});
const showTab = (name) => document.querySelector('.tabs button[data-tab="' + name + '"]').click();
// ---- model settings -------------------------------------------------------------------
async function loadCfg() {
try {
cfg = await api('/v1/agent/settings');
$('cfgHost').value = cfg.llmHost || '';
$('cfgModel').value = cfg.llmModel || '';
$('cfgAuto').checked = !!cfg.autoAct;
$('cfgSteps').value = cfg.maxSteps || 60;
$('cfgKeyState').textContent = cfg.keySet ? ('set ' + cfg.keyHint) : 'not set';
$('cfgKey2State').textContent = cfg.keys2Set ? 'set' : 'not set';
// The one thing worth saying on the main panel: whether it can run at all, and whether it will
// act on its own. Both are answers you want BEFORE typing a job, not after.
$('modelState').innerHTML = cfg.keySet || !/ollama\.com/.test(cfg.llmHost || '')
? (cfg.llmModel + (cfg.autoAct ? ' · acts without asking' : ' · asks before acting'))
: 'no API key yet — press Model';
} catch (e) { $('modelState').textContent = e.message; }
}
/* In order, not in parallel: loadModels marks the CURRENT model as selected, and it only knows
which that is once loadCfg has answered. Fired together, the select came back with the first
model in the list highlighted instead of the one actually configured. */
$('agentCfg').onclick = async () => {
$('cfgModal').classList.remove('hide');
loadSiteRow();
$('cfgModels').textContent = 'asking the host…';
await loadCfg();
await loadModels();
};
const CUSTOM = '__custom__';
/* Keep the free-text box in step with the select. It only exists for a host serving something it
does not advertise; the rest of the time it would just be a second place to get the name wrong. */
function syncModelInput() {
const custom = $('cfgModelSel').value === CUSTOM;
$('cfgModel').classList.toggle('hide', !custom);
if (custom) $('cfgModel').focus();
}
$('cfgModelSel').onchange = syncModelInput;
async function loadModels(force) {
const sel = $('cfgModelSel');
try {
const r = await api('/v1/agent/models' + (force ? '?refresh=1' : ''), {
method:'POST',
body: JSON.stringify({ llmHost: $('cfgHost').value.trim(), ...($('cfgKey').value.trim() ? { llmKey: $('cfgKey').value.trim() } : {}), ...($('cfgKey2').value.trim() ? { llmKeys: $('cfgKey2').value.trim() } : {}) }),
});
const models = r.models || [];
const current = cfg.llmModel || $('cfgModel').value || '';
// Keep whatever is configured selectable even if the host no longer lists it — silently
// switching someone's model because it fell off a list is a worse outcome than showing it.
const options = models.includes(current) || !current ? models : [current, ...models];
sel.innerHTML = options.map(m =>
'').join('')
+ '';
syncModelInput();
// Say where the list came from. A fallback list that looks fetched is worse than no list —
// you pick a model that is not there and find out forty seconds into a job.
$('cfgModels').textContent = r.fetched
? (models.length + ' models on ' + r.host)
: 'could not list models (' + (r.reason || 'unknown') + ') — these are the usual cloud ones';
$('cfgModels').className = r.fetched ? 'cap' : 'cap warn';
} catch (e) { $('cfgModels').textContent = e.message; $('cfgModels').className = 'cap warn'; }
}
$('cfgRefresh').onclick = () => loadModels(true);
$('cfgClose').onclick = () => $('cfgModal').classList.add('hide');
/* The select unless it says "type a name", in which case the box beneath it. */
const chosenModel = () =>
($('cfgModelSel').value === CUSTOM ? $('cfgModel').value.trim() : $('cfgModelSel').value)
|| cfg.llmModel || '';
const cfgBody = () => ({
llmHost: $('cfgHost').value.trim(), llmModel: chosenModel(),
autoAct: $('cfgAuto').checked, maxSteps: Number($('cfgSteps').value) || 60,
// Left blank means "keep the key you already have", not "clear it" - clearing is rare and
// deleting someone's key because they did not retype it would be its own bug.
...($('cfgKey').value.trim() ? { llmKey: $('cfgKey').value.trim() } : {}),
...($('cfgKey2').value.trim() ? { llmKeys: $('cfgKey2').value.trim() } : {}),
});
$('cfgSave').onclick = async () => {
try { await api('/v1/agent/settings', { method:'PUT', body: JSON.stringify(cfgBody()) });
$('cfgKey').value = ''; $('cfgKey2').value = ''; $('cfgMsg').textContent = 'saved'; $('cfgMsg').className = 'msg ok'; loadCfg(); }
catch (e) { $('cfgMsg').textContent = e.message; $('cfgMsg').className = 'msg bad'; }
};
// Proving the key works takes three seconds here and saves a job that dies forty seconds in.
$('cfgTest').onclick = async () => {
$('cfgMsg').textContent = 'asking the model...'; $('cfgMsg').className = 'msg';
try { const r = await api('/v1/agent/test', { method:'POST', body: JSON.stringify(cfgBody()) });
$('cfgMsg').textContent = 'works - ' + r.model + ' answered in ' + r.ms + 'ms'; $('cfgMsg').className = 'msg ok'; }
catch (e) { $('cfgMsg').textContent = e.message; $('cfgMsg').className = 'msg bad'; }
};
// The company profile lived here. It now lives in LeadFlow's Settings, where it already did —
// two copies of the same facts is not redundancy, it is a guarantee that one is stale with no way
// to tell which. The agent reads LeadFlow's, server-side, when a job starts.
// ---- rendering the work ---------------------------------------------------------------
/* What a step is called when a person reads it back. The kinds that are RESULTS of a tool call are
rendered subordinate to it rather than as events of their own — see below. */
const KIND_LABEL = { lead:'lead', acted:'sent', skipped:'skipped', blocked:'refused',
think:'thinking', you:'you', note:'note', learned:'learned',
error:'problem', done:'done', end:'ended' };
/* Everything a tool call produces. These hang off the call rather than starting a new block, which
is the difference between a transcript and a wall. */
const RESULT_KINDS = new Set(['look', 'read', 'open', 'click', 'type', 'scroll']);
const atBottom = () => { const f = $('feed'); return f.scrollHeight - f.scrollTop - f.clientHeight < 90; };
function addStep(st) {
// A proposal renders as its own card; the "asking you" step would just duplicate it.
if (st.kind === 'ask') return;
const stick = atBottom();
const d = document.createElement('div');
if (st.kind === 'tool') {
// tool(args) — the ASK, before the page had a chance to answer.
const m = String(st.text).match(/^([a-z_]+)\((.*)\)$/s);
d.className = 'ev tool';
d.innerHTML = m
? '' + esc(m[1]) + '(' + esc(m[2]) + ')'
: esc(st.text);
if (st.args && Object.keys(st.args).length) {
// The full arguments are one click away rather than always on screen: you want them when
// something went wrong and never otherwise.
const a = document.createElement('a');
a.href = '#'; a.className = 'peek'; a.textContent = 'args';
const pre = document.createElement('pre'); pre.hidden = true;
pre.textContent = JSON.stringify(st.args, null, 2);
a.onclick = (e) => { e.preventDefault(); pre.hidden = !pre.hidden; };
d.append(a, pre);
}
} else if (RESULT_KINDS.has(st.kind)) {
d.className = 'ev res';
d.textContent = st.text;
} else {
d.className = 'ev ' + st.kind;
d.innerHTML = '' + (KIND_LABEL[st.kind] || st.kind) + '' + esc(st.text);
}
$('feed').appendChild(d);
if (stick) d.scrollIntoView({ block:'end' });
}
const esc = (t) => String(t == null ? '' : t).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
function renderProposal(p) {
let card = document.getElementById('prop-' + p.pid);
if (!card) {
card = document.createElement('div');
card.className = 'ev prop'; card.id = 'prop-' + p.pid;
$('feed').appendChild(card);
card.scrollIntoView({ block:'end' });
}
if (p.state !== 'pending') {
card.className = 'ev prop done';
card.innerHTML = '' + (p.state === 'approved' ? 'you approved' : 'you skipped') + ''
+ '' + esc(p.kind) + '' + (p.text ? '
' + esc(p.text) : ' — ' + esc(p.label));
refreshBadge();
return;
}
card.className = 'ev prop';
card.innerHTML =
'
' + esc(p.why) + '
' : '') + (p.text !== undefined && p.text !== '' ? '' : '') + '