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.
master
John Lauer
v2.0.397 (insiders): First-run fix (arav-rog): npm install can no longer fail on puppeteer's postinstall Chrome downloa…
8b4f135
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// consent.js — the human-consent registry behind pup's approval toasts (v2.0.397, pup-bridge#92).
//
// ab's BRIDGE_SDK hard rule (ab 2.1.30, adom-bridge#142/#137): a verb parked on a HUMAN decision
// must never end in a bare transport timeout. It answers with a structured, non-terminal
// { success:false, errorCode:"blocked_on_user", stillRunning:true, blockedBy, _hint }
// so the calling AI can tell "waiting on the user" from "failed".
//
// pup's approval toasts (pup_credential_reveal, pup_fill_card) used to block the verb for the
// toast's full 120 s. That is longer than some ab per-verb budgets (a 60 s cap was measured on
// ab 2.0.97), so a slow human produced exactly the bare timeout the rule forbids, while the toast
// stayed up and a late click authorised something nobody was listening for.
//
// The registry fixes that in one place:
// - the verb waits IN-VERB for at most inVerbMs (45 s, under every ab budget), then returns
// blocked_on_user with stillRunning:true; the toast stays up and the decision keeps being
// collected in the background for totalSec (120 s);
// - a re-call of the SAME verb with the SAME args (same key) RE-ATTACHES to the pending toast:
// no second toast, and a decision that arrived in between is handed over exactly ONCE;
// - on the toast's own timeout the sticky toast is retracted (notify_dismiss) and the recorded
// outcome is consent_timeout, picked up by the next re-call or dropped after pickupMs.
//
// Pure module: everything OS-facing (notify_user / notify_dismiss) comes in through `abCommand`.
'use strict';
function createRegistry({ abCommand, abPayload, inVerbMs = 45000, totalSec = 120, pickupMs = 120000, log = () => {}, now = Date.now, setTimer = setTimeout } = {}) {
if (typeof abCommand !== 'function') throw new Error('consent registry needs abCommand(verb, args, opts)');
const payloadOf = typeof abPayload === 'function' ? abPayload : (r) => r;
const map = new Map(); // consentId -> record { id, key, verb, what, since, decided, decision, pickedUp, attached, promise }
function findPending(key) {
for (const rec of map.values()) if (rec.key === key && !rec.pickedUp) return rec;
return null;
}
// Resolve to the decision string (the button label) or null on timeout / transport error.
function collect(rec, toast, callerThread, callerReason) {
return (async () => {
let decision = null;
try {
const r = await abCommand('notify_user', { id: rec.id, ...toast, wait: true, timeoutSec: totalSec }, { timeoutMs: (totalSec + 10) * 1000, callerThread, callerReason });
const p = payloadOf(r) || {};
decision = p.action || (r && r.action) || null;
} catch (e) { decision = null; }
rec.decision = decision; rec.decided = now();
let retract = 'n/a';
if (!decision) {
// A reminder toast is STICKY: ab never auto-dismisses it, timeoutSec only bounds the wait.
try { const rr = await abCommand('notify_dismiss', { id: rec.id }, { timeoutMs: 8000 }); const pp = payloadOf(rr) || {}; retract = String(pp.dismissed !== undefined ? pp.dismissed : (rr && rr.dismissed)); }
catch (e) { retract = 'error: ' + e.message; }
}
log(rec, decision, retract);
const t = setTimer(() => { if (map.get(rec.id) === rec) map.delete(rec.id); }, pickupMs);
if (t && typeof t.unref === 'function') t.unref();
return decision;
})();
}
// opts: { key, verb, what, toast:{ idPrefix, ...notify_user args (title, body, buttons, level, scenario) }, callerThread, callerReason }
// -> { pending:true, consentId, since, attached, remainingSec }
// -> { pending:false, consentId, decision:string|null, attached }
async function ask(opts) {
const { key, verb, what, toast, callerThread, callerReason } = opts || {};
if (!key || !verb || !toast) throw new Error('consent.ask needs key, verb, toast');
let rec = findPending(key);
if (!rec) {
const { idPrefix, ...toastArgs } = toast;
rec = { id: `${idPrefix || 'consent'}-${now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, key, verb, what: what || `Waiting for the user to answer "${toastArgs.title || verb}"`, since: now(), decided: null, decision: undefined, pickedUp: false, attached: 0, promise: null };
rec.promise = collect(rec, toastArgs, callerThread, callerReason);
map.set(rec.id, rec);
} else {
rec.attached++;
}
let capTimer = null;
const cap = new Promise((resolve) => { capTimer = setTimer(() => resolve('__pending__'), inVerbMs); }); // NOT unref'd: the verb is awaiting it
const outcome = await Promise.race([rec.promise, cap]);
if (capTimer) clearTimeout(capTimer);
if (outcome === '__pending__') {
const remainingSec = Math.max(0, Math.round(totalSec - (now() - rec.since) / 1000));
return { pending: true, consentId: rec.id, since: rec.since, attached: rec.attached, remainingSec };
}
rec.pickedUp = true; map.delete(rec.id);
return { pending: false, consentId: rec.id, decision: rec.decision, attached: rec.attached };
}
// Rows for pup_readiness.active[]: still-open toasts are blockedOnUser; a decided-but-unpicked
// record shows as consent_decided so a poller knows to re-call the verb and collect it.
function rows(at) {
const t = at || now(); const out = [];
for (const [id, c] of map) {
const decided = !!c.decided;
out.push({
id, verb: c.verb, phase: decided ? 'consent_decided' : 'consent', stepLabel: decided ? `The user answered; re-call ${c.verb} with the same args to collect the decision` : c.what,
percent: null, elapsedSec: Math.round((t - c.since) / 1000), estimatedSec: null, etaSec: null, confidence: 'measured',
blockedOnUser: !decided, blockedBy: decided ? null : 'your Approve/Deny click on the desktop toast', reattach: `${c.verb} again with the SAME args`,
});
}
return out;
}
return { ask, rows, map, inVerbMs, totalSec };
}
// The response shape every blocked-on-a-human path returns (SDK 2.1.30). stillRunning:true = the
// wait continues in the background (re-call the same verb to re-attach); false = the human must
// act first, then the caller re-issues the verb.
function blockedOnUser(extra) {
return Object.assign({ success: false, status: 'error', errorCode: 'blocked_on_user', stillRunning: false }, extra || {});
}
module.exports = { createRegistry, blockedOnUser };