teamshare-bridge 0.21.26 → 0.21.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,6 +59,8 @@ const child_process_1 = require("child_process");
59
59
  const cdp_1 = require("../../lib/browser/cdp");
60
60
  const launch_1 = require("../../lib/browser/launch");
61
61
  const session_1 = require("../../lib/browser/session");
62
+ const personal_fields_1 = require("../../lib/browser/personal-fields");
63
+ const human_input_guard_1 = require("../../lib/browser/human-input-guard");
62
64
  /** Create a fingerprint from the current element list. */
63
65
  function createFingerprint(elements) {
64
66
  const groups = new Map();
@@ -114,12 +116,25 @@ function calculateChangeScore(prev, curr) {
114
116
  }
115
117
  return score;
116
118
  }
119
+ /** Fast path (easy objective questions): small reasoning budget. */
120
+ const FAST_MAX_TOKENS = 2048;
121
+ /** Deep path (hard coding questions): enough tokens to emit a full solution. */
122
+ const DEEP_MAX_TOKENS = 16384;
123
+ /** Attempt-1 ceiling: abandon a slow easy answer and escalate to the deep path. */
124
+ const FAST_ATTEMPT_TIMEOUT_MS = 45_000;
125
+ /**
126
+ * Rule 0 is swapped per attempt: the fast path forbids long deliberation, the
127
+ * deep path (entered when the fast attempt fails) explicitly allows it so hard
128
+ * coding questions get reasoned/complete answers.
129
+ */
130
+ const FAST_RULE_0 = '0. BE FAST AND DECISIVE. Answer in a single pass with minimal reasoning - do NOT deliberate at length, re-check repeatedly, or restate the page. Emit the final JSON array as your primary output. Speed matters more than exhaustive deliberation.';
131
+ const DEEP_RULE_0 = '0. This may be a HARD problem (advanced coding / algorithms). Reason carefully before acting; correctness and efficiency matter more than speed. For coding questions, write the most time-efficient solution and emit the FULL solution via write_code.';
117
132
  const SYSTEM_PROMPT = `You are a browser automation agent. A screenshot and a grouped list of interactive elements are attached.
118
133
 
119
134
  Your task: identify unanswered questions and answer them by clicking the correct elements.
120
135
 
121
136
  RULES (STRICT):
122
- 0. BE FAST AND DECISIVE. Answer in a single pass with minimal reasoning — do NOT deliberate at length, re-check repeatedly, or restate the page. Emit the final JSON array as your primary output. Speed matters more than exhaustive deliberation.
137
+ ${FAST_RULE_0}
123
138
  1. NEVER click Next, Submit, Back, Save, Continue, or any navigation. ONLY answer questions on the current screen.
124
139
  2. NEVER ask for help. If uncertain, pick the BEST GUESS. Always decide — never wait.
125
140
  3. Handle MULTIPLE unanswered questions in a SINGLE response.
@@ -239,10 +254,10 @@ CORRECTION MODE:
239
254
  - If you previously typed wrong code, use write_code to replace with correct code
240
255
  - You may be asked the same question multiple times — each time, check if the current answer is correct before acting
241
256
 
242
- PERSONAL QUESTIONS — SKIP THEM:
243
- - NEVER type answers for questions asking: name, email, phone, address, SSN, password, credit card, date of birth, or any personal information
244
- - If a question asks for personal info, do NOT click or type — skip it entirely
245
- - If ALL remaining questions are personal, return done
257
+ PERSONAL QUESTIONS — NEVER ANSWER THEM:
258
+ - Questions about the person operating the browser (name, email, phone, address, SSN, passport, password, card, bank, date of birth, salary, work authorization, personal profile links) are OFF LIMITS. Read the PERSONAL QUESTIONS addendum appended below and follow it exactly.
259
+ - If a question asks for personal info, do NOT click or type — leave it empty.
260
+ - A personal field left empty is NOT an unanswered question. If ALL remaining questions are personal, return done immediately.
246
261
 
247
262
  RESPONSE FORMAT RULES (CRITICAL — follow exactly):
248
263
  - Return ONLY a valid JSON array. No explanations, no markdown fences, no extra text.
@@ -444,16 +459,16 @@ async function cmdBrowse(flags) {
444
459
  const maxActions = continuous ? Number.MAX_SAFE_INTEGER : parseInt(flags.get('max-actions') ?? '50', 10);
445
460
  const intervalMs = parseInt(flags.get('interval') ?? '2000', 10);
446
461
  const modelSpec = flags.get('model') ?? models_1.DEFAULT_VISION_MODEL;
447
- // Target: a decisive answer in well under a minute. 60s is the abandon
448
- // threshold (overridable); the fast abort + short retry below makes giving up
449
- // cheap, so we never wait 5 minutes for a stale decision.
450
- const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '60', 10), 20), 600);
462
+ // Hard problems (advanced coding) need time; easy objective questions must
463
+ // not pay for it. 300s is the ceiling for a single decision (overridable);
464
+ // the fast retry below abandons a slow easy answer at ~45s and escalates.
465
+ const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '300', 10), 30), 600);
451
466
  const timeoutMs = timeoutSec * 1000;
452
467
  const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '70', 10), 50), 100);
453
468
  /**
454
- * Downscale the screenshot when the viewport is bigger than this many px on
455
- * its longest side (fewer image tokens => faster vision response on big
456
- * pages; a 1440px page produced 100KB+ images that blew the timeout).
469
+ * Downscale the FAST-path screenshot (attempt 1) to keep easy questions
470
+ * quick. The deep retry re-captures at full resolution so code/text stays
471
+ * legible. Clicks are unaffected either way (actions use element coords).
457
472
  */
458
473
  const SCREENSHOT_MAX_EDGE = 1024;
459
474
  const maxConsecutiveFailures = parseInt(flags.get('max-consecutive-failures') ?? '0', 10); // 0 = unlimited
@@ -462,6 +477,15 @@ async function cmdBrowse(flags) {
462
477
  const stealth = flags.has('stealth');
463
478
  const hybrid = flags.has('hybrid');
464
479
  const autoLaunch = flags.has('auto-launch');
480
+ // Field safety (see personal-fields.ts / human-input-guard.ts):
481
+ // --stop-on-user-input opt-in: stop typing and skip the field for the run
482
+ // as soon as the human types into it
483
+ // --personal-questions "allow" answers personal questions; credentials and
484
+ // card/bank fields stay blocked either way
485
+ const stopOnUserInput = flags.has('stop-on-user-input') && flags.get('stop-on-user-input') !== 'false';
486
+ const personalMode = (flags.get('personal-questions') ?? 'skip').toLowerCase();
487
+ fieldSafety.stopOnUserInput = stopOnUserInput;
488
+ fieldSafety.allowPersonal = personalMode === 'allow';
465
489
  // The auto-answer browser is ALWAYS visible (2026-09): a headless window is
466
490
  // the "profile seems mismatched" case and hides the pages the loop works on.
467
491
  // `--headless` is accepted for backward compatibility but ignored.
@@ -493,6 +517,8 @@ async function cmdBrowse(flags) {
493
517
  }
494
518
  console.log(`[browse] Char delay: ${charDelayMs}ms`);
495
519
  console.log(`[browse] Screenshot interval: ${intervalMs}ms`);
520
+ console.log(`[browse] Personal questions: ${fieldSafety.allowPersonal ? 'answered (credentials still blocked)' : 'never answered (left for you)'}`);
521
+ console.log(`[browse] Stop typing when you type in the field: ${fieldSafety.stopOnUserInput ? 'on' : 'off'}`);
496
522
  if (!stealth) {
497
523
  console.log(`[browse] Max consecutive failures: ${maxConsecutiveFailures === 0 ? 'unlimited' : maxConsecutiveFailures}`);
498
524
  }
@@ -676,7 +702,20 @@ async function cmdBrowse(flags) {
676
702
  /** The page changed under the LLM: re-analyze immediately, no backoff. */
677
703
  let screenChangedDuringLlm = false;
678
704
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
679
- result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, attempt, maxRetries, page, lastFingerprint);
705
+ // Fast-first ladder: attempt 1 uses a small budget + downscaled shot so
706
+ // easy objective questions answer quickly. If it fails, attempts 2+
707
+ // switch to the DEEP budget (full timeout + tokens, full-res capture)
708
+ // for hard coding questions. `max_tokens`/timeout are ceilings, so the
709
+ // deep path never slows a question that answers on the fast path.
710
+ const deep = attempt > 1;
711
+ const attemptTimeoutMs = deep ? timeoutMs : Math.min(FAST_ATTEMPT_TIMEOUT_MS, timeoutMs);
712
+ if (deep && attempt === 2) {
713
+ console.log('[browse] Fast attempt did not answer — escalating to the deep budget (more time + tokens)');
714
+ }
715
+ const attemptShot = deep
716
+ ? ((await captureScreenshot(page, quality)) ?? screenshot)
717
+ : screenshot;
718
+ result = await analyzeScreenshot(baseUrl, apiKey, model, attemptShot, elements, pageContext, attemptTimeoutMs, attempt, maxRetries, page, lastFingerprint, { maxTokens: deep ? DEEP_MAX_TOKENS : FAST_MAX_TOKENS, deep });
680
719
  llmCallCount++;
681
720
  llmStats.calls++;
682
721
  llmStats.totalMs += result.durationMs;
@@ -780,7 +819,7 @@ async function cmdBrowse(flags) {
780
819
  if (hasUnansweredQuestions(elements)) {
781
820
  console.log(`[browse] LLM says done but unanswered questions remain — retrying...`);
782
821
  // Force retry with stronger prompt
783
- result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, 1, 1, page, lastFingerprint);
822
+ result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, 1, 1, page, lastFingerprint, { maxTokens: DEEP_MAX_TOKENS, deep: true });
784
823
  if (result.actions.length > 0 && result.actions[0].action !== 'done') {
785
824
  // LLM returned actual actions — re-process from the start of this loop
786
825
  executedAny = false;
@@ -826,6 +865,18 @@ async function cmdBrowse(flags) {
826
865
  console.log(`[browse] Skipping ${act.action} — code already confirmed optimal`);
827
866
  continue;
828
867
  }
868
+ // Field safety: never type into a personal field, a credential field, or
869
+ // a field the human already answered themselves.
870
+ if (act.action === 'write_code' || act.action === 'type') {
871
+ const targetEl = act.elementIndex !== undefined
872
+ ? elements.find((e) => e.i === act.elementIndex)
873
+ : undefined;
874
+ const gated = fieldGate(targetEl);
875
+ if (gated) {
876
+ console.log(`[browse] Skipping element ${act.elementIndex} - ${gated}`);
877
+ continue;
878
+ }
879
+ }
829
880
  // Ask LLM to decide if action is redundant
830
881
  const redundancy = await isActionRedundant(baseUrl, apiKey, model, page, elements, act, timeoutMs);
831
882
  if (redundancy.decision === 'skip') {
@@ -1017,6 +1068,14 @@ Do NOT simplify variable names or change the function signature. Only optimize t
1017
1068
  console.log(`[browse] Reached max actions (${maxActions})`);
1018
1069
  }
1019
1070
  console.log(`[browse] Agent finished. ${actionCount} actions performed.`);
1071
+ const skipped = personalSkipSummary();
1072
+ if (skipped.personal > 0 || skipped.human > 0) {
1073
+ console.log(`[browse] Left for you: ${skipped.personal} personal question(s) never answered` +
1074
+ (skipped.human > 0 ? `, ${skipped.human} field(s) you answered yourself` : ''));
1075
+ for (const entry of skippedFields.values()) {
1076
+ console.log(` - ${entry.note}`);
1077
+ }
1078
+ }
1020
1079
  page.ws.close();
1021
1080
  }
1022
1081
  // ── CDP Helpers ──────────────────────────────────────────────────────────────
@@ -1184,6 +1243,124 @@ async function captureScreenshot(page, quality, maxEdge = 0) {
1184
1243
  return null;
1185
1244
  }
1186
1245
  }
1246
+ /**
1247
+ * Field-safety addendum appended to every system prompt (main loop, custom /
1248
+ * optimization passes, stealth loop). The loop may not answer questions that
1249
+ * ask about the human behind the browser.
1250
+ */
1251
+ const PERSONAL_SAFETY_RULES = `
1252
+ PERSONAL QUESTIONS - NEVER ANSWER THEM:
1253
+ - Some questions ask about YOU (the human operating the browser), not about the
1254
+ task. NEVER answer them with an invented value.
1255
+ - Never type: names, email addresses, phone numbers, postal addresses, dates of
1256
+ birth or age, government IDs (SSN/NIN/passport/driving licence/tax id),
1257
+ passwords, one-time codes, card or bank details, salary or compensation,
1258
+ notice periods, work-authorization/visa/sponsorship answers, or personal
1259
+ profile links (LinkedIn, GitHub, portfolio, personal website).
1260
+ - Fields marked "[NEVER ANSWER: ...]" or "[PERSONAL: ...]" in the element list
1261
+ MUST be left untouched - do not click them, do not type into them.
1262
+ - Leaving a personal field EMPTY is correct and expected. A personal field is
1263
+ NOT an unanswered question: never treat it as one, and never delay "done"
1264
+ because of it.
1265
+ - If every remaining question is personal, return done.
1266
+ - Demographics and free-text personal questions (gender, marital status, "tell
1267
+ us about yourself") are the human's to answer: prefer to skip them too.
1268
+ `;
1269
+ const fieldSafety = {
1270
+ stopOnUserInput: false,
1271
+ allowPersonal: false,
1272
+ };
1273
+ /** fieldKey -> why the field must not be typed into for the rest of the run. */
1274
+ const skippedFields = new Map();
1275
+ /**
1276
+ * Stealth mode has no DOM, so fields are identified by the focused OS control
1277
+ * (AutomationId|ClassName|Name) instead of a field key.
1278
+ */
1279
+ const skippedOsFields = new Map();
1280
+ function signalsOf(el) {
1281
+ return {
1282
+ tag: el.tag,
1283
+ type: el.type,
1284
+ name: el.name,
1285
+ id: el.id,
1286
+ autocomplete: el.autocomplete,
1287
+ placeholder: el.placeholder,
1288
+ ariaLabel: el.ariaLabel,
1289
+ label: el.label,
1290
+ questionText: el.questionText,
1291
+ text: el.text,
1292
+ };
1293
+ }
1294
+ function classificationOf(el) {
1295
+ return {
1296
+ tier: el.personalTier ?? null,
1297
+ category: el.personalCategory ?? null,
1298
+ label: el.personalLabel ?? '',
1299
+ reason: el.personalReason ?? '',
1300
+ };
1301
+ }
1302
+ /**
1303
+ * Classify every typable element and stamp its stable field key. Elements are
1304
+ * annotated in place so the gate below never re-parses the DOM.
1305
+ */
1306
+ function annotateFieldSafety(elements) {
1307
+ for (const el of elements) {
1308
+ const signals = signalsOf(el);
1309
+ el.fieldKey = (0, personal_fields_1.fieldKeyOf)(signals);
1310
+ if (!el.typable)
1311
+ continue;
1312
+ const cls = (0, personal_fields_1.classifyField)(signals);
1313
+ el.personalTier = cls.tier;
1314
+ el.personalCategory = cls.category;
1315
+ el.personalLabel = cls.label;
1316
+ el.personalReason = cls.reason;
1317
+ }
1318
+ }
1319
+ function shortLabel(el) {
1320
+ const raw = el.label || el.questionText || el.text || el.placeholder || el.name || '';
1321
+ return raw.replace(/\s+/g, ' ').trim().slice(0, 60);
1322
+ }
1323
+ /**
1324
+ * Returns a human-readable reason when this element must not be typed into,
1325
+ * or null when it is fine to type.
1326
+ */
1327
+ function fieldGate(el) {
1328
+ if (!el || !el.typable)
1329
+ return null;
1330
+ const key = el.fieldKey ?? (0, personal_fields_1.fieldKeyOf)(signalsOf(el));
1331
+ const already = skippedFields.get(key);
1332
+ if (already)
1333
+ return already.note;
1334
+ const cls = classificationOf(el);
1335
+ if (cls.tier === null)
1336
+ return null;
1337
+ if (!(0, personal_fields_1.isBlocked)(cls, { allowPersonal: fieldSafety.allowPersonal }))
1338
+ return null;
1339
+ const note = `left to you (${cls.category}: "${shortLabel(el) || cls.label}")`;
1340
+ skippedFields.set(key, { kind: 'personal', note });
1341
+ console.log(`[browse] Not answering a personal question - ${note}`);
1342
+ return note;
1343
+ }
1344
+ /** Record a field the human took over mid-typing; it is never typed again. */
1345
+ function noteHumanTookOver(el, reason) {
1346
+ const key = el?.fieldKey ?? (el ? (0, personal_fields_1.fieldKeyOf)(signalsOf(el)) : '');
1347
+ if (!key)
1348
+ return;
1349
+ const note = `you answered this one yourself (${reason})`;
1350
+ skippedFields.set(key, { kind: 'human', note });
1351
+ console.log(`[browse] Stopped typing - ${note}`);
1352
+ }
1353
+ function personalSkipSummary() {
1354
+ let personal = 0;
1355
+ let human = 0;
1356
+ for (const entry of skippedFields.values()) {
1357
+ if (entry.kind === 'personal')
1358
+ personal++;
1359
+ else
1360
+ human++;
1361
+ }
1362
+ return { personal, human };
1363
+ }
1187
1364
  /**
1188
1365
  * Extract all interactive elements from the DOM with their exact text,
1189
1366
  * bounding boxes, state, and question group assignments.
@@ -1224,6 +1401,29 @@ async function extractElements(page) {
1224
1401
  text,
1225
1402
  tag: el.tagName.toLowerCase(),
1226
1403
  type: el.type || el.getAttribute('role') || '',
1404
+ typable: el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable === true,
1405
+ name: el.getAttribute('name') || '',
1406
+ id: el.getAttribute('id') || '',
1407
+ autocomplete: el.getAttribute('autocomplete') || '',
1408
+ placeholder: el.getAttribute('placeholder') || '',
1409
+ ariaLabel: el.getAttribute('aria-label') || '',
1410
+ label: (() => {
1411
+ try {
1412
+ const id = el.getAttribute('id');
1413
+ if (id) {
1414
+ const forLabel = document.querySelector('label[for="' + CSS.escape(id) + '"]');
1415
+ if (forLabel && forLabel.innerText) return forLabel.innerText.trim().replace(/\\s+/g, ' ').slice(0, 200);
1416
+ }
1417
+ const wrap = el.closest ? el.closest('label') : null;
1418
+ if (wrap && wrap.innerText) return wrap.innerText.trim().replace(/\\s+/g, ' ').slice(0, 200);
1419
+ const labelled = el.getAttribute('aria-labelledby');
1420
+ if (labelled) {
1421
+ const t = document.getElementById(labelled);
1422
+ if (t && t.innerText) return t.innerText.trim().replace(/\\s+/g, ' ').slice(0, 200);
1423
+ }
1424
+ } catch (err) {}
1425
+ return '';
1426
+ })(),
1227
1427
  checked: el.checked || el.getAttribute('aria-checked') === 'true' || false,
1228
1428
  x: Math.round(rect.x + rect.width / 2),
1229
1429
  y: Math.round(rect.y + rect.height / 2),
@@ -1336,6 +1536,13 @@ async function extractElements(page) {
1336
1536
  y: item.y,
1337
1537
  group: item.groupId,
1338
1538
  inViewport: item.inViewport,
1539
+ typable: item.typable,
1540
+ name: item.name,
1541
+ id: item.id,
1542
+ autocomplete: item.autocomplete,
1543
+ placeholder: item.placeholder,
1544
+ ariaLabel: item.ariaLabel,
1545
+ label: item.label,
1339
1546
  });
1340
1547
  });
1341
1548
 
@@ -1344,7 +1551,9 @@ async function extractElements(page) {
1344
1551
  `;
1345
1552
  try {
1346
1553
  const result = await cdpEval(page, js);
1347
- return result?.result?.value || [];
1554
+ const elements = result?.result?.value || [];
1555
+ annotateFieldSafety(elements);
1556
+ return elements;
1348
1557
  }
1349
1558
  catch {
1350
1559
  return [];
@@ -1393,6 +1602,11 @@ async function executeAction(page, act, elements, charDelayMs) {
1393
1602
  console.log(`[browse] Element index ${act.elementIndex} not found, will retry`);
1394
1603
  return false;
1395
1604
  }
1605
+ const gated = fieldGate(el);
1606
+ if (gated) {
1607
+ console.log(`[browse] Skipping element ${el.i} - ${gated}`);
1608
+ return true;
1609
+ }
1396
1610
  // 1. Detect editor type
1397
1611
  const editor = await detectEditor(page);
1398
1612
  const isCodeEditor = editor !== null;
@@ -1407,12 +1621,19 @@ async function executeAction(page, act, elements, charDelayMs) {
1407
1621
  // 3. Normalize indentation for code editors (LLM often adds extra indent)
1408
1622
  const text = isCodeEditor ? normalizeCodeIndent(rawText) : rawText;
1409
1623
  console.log(`[browse] Typing ${text.length} characters...`);
1624
+ const guard = new human_input_guard_1.TypingGuard({
1625
+ enabled: fieldSafety.stopOnUserInput,
1626
+ page,
1627
+ label: shortLabel(el),
1628
+ });
1410
1629
  if (isCodeEditor) {
1411
- await insertIntoCodeEditor(page, editor.selector, text, charDelayMs);
1630
+ await insertIntoCodeEditor(page, editor.selector, text, charDelayMs, guard);
1412
1631
  }
1413
1632
  else {
1414
- await insertIntoGenericInput(page, el, text, charDelayMs);
1633
+ await insertIntoGenericInput(page, el, text, charDelayMs, guard);
1415
1634
  }
1635
+ if (guard.stopped)
1636
+ noteHumanTookOver(el, guard.describeStop());
1416
1637
  return true;
1417
1638
  }
1418
1639
  case 'write_code': {
@@ -1423,6 +1644,11 @@ async function executeAction(page, act, elements, charDelayMs) {
1423
1644
  console.log(`[browse] Element index ${act.elementIndex} not found, will retry`);
1424
1645
  return false;
1425
1646
  }
1647
+ const gated = fieldGate(el);
1648
+ if (gated) {
1649
+ console.log(`[browse] Skipping element ${el.i} - ${gated}`);
1650
+ return true;
1651
+ }
1426
1652
  // 1. Detect editor type
1427
1653
  const editor = await detectEditor(page);
1428
1654
  const isCodeEditor = editor !== null;
@@ -1435,13 +1661,20 @@ async function executeAction(page, act, elements, charDelayMs) {
1435
1661
  .replace(/\\"/g, '"')
1436
1662
  .replace(/\\\\/g, '\\');
1437
1663
  console.log(`[browse] Writing code (${text.length} chars)...`);
1664
+ const guard = new human_input_guard_1.TypingGuard({
1665
+ enabled: fieldSafety.stopOnUserInput,
1666
+ page,
1667
+ label: shortLabel(el),
1668
+ });
1438
1669
  // 3. write_code inserts EXACTLY as-is — no normalizeCodeIndent
1439
1670
  if (isCodeEditor) {
1440
- await insertIntoCodeEditor(page, editor.selector, text, charDelayMs);
1671
+ await insertIntoCodeEditor(page, editor.selector, text, charDelayMs, guard);
1441
1672
  }
1442
1673
  else {
1443
- await insertIntoGenericInput(page, el, text, charDelayMs);
1674
+ await insertIntoGenericInput(page, el, text, charDelayMs, guard);
1444
1675
  }
1676
+ if (guard.stopped)
1677
+ noteHumanTookOver(el, guard.describeStop());
1445
1678
  return true;
1446
1679
  }
1447
1680
  case 'scroll': {
@@ -1515,6 +1748,11 @@ async function executeActionHybrid(page, act, elements, charDelayMs) {
1515
1748
  console.log(`[browse] Element index ${act.elementIndex} not found, will retry`);
1516
1749
  return false;
1517
1750
  }
1751
+ const gated = fieldGate(el);
1752
+ if (gated) {
1753
+ console.log(`[browse] Skipping element ${el.i} - ${gated}`);
1754
+ return true;
1755
+ }
1518
1756
  const editor = await detectEditor(page);
1519
1757
  const isCodeEditor = editor !== null;
1520
1758
  if (editor)
@@ -1533,7 +1771,16 @@ async function executeActionHybrid(page, act, elements, charDelayMs) {
1533
1771
  pressKeyOS('ctrl+a');
1534
1772
  await sleep(randomInt(50, 100));
1535
1773
  }
1536
- await typeHumanLike(text, charDelayMs);
1774
+ const guard = new human_input_guard_1.TypingGuard({
1775
+ enabled: fieldSafety.stopOnUserInput,
1776
+ page,
1777
+ label: shortLabel(el),
1778
+ });
1779
+ if (guard)
1780
+ await guard.arm({ isCodeEditor });
1781
+ await typeHumanLike(text, charDelayMs, guard);
1782
+ if (guard.stopped)
1783
+ noteHumanTookOver(el, guard.describeStop());
1537
1784
  return true;
1538
1785
  }
1539
1786
  case 'write_code': {
@@ -1544,6 +1791,11 @@ async function executeActionHybrid(page, act, elements, charDelayMs) {
1544
1791
  console.log(`[browse] Element index ${act.elementIndex} not found, will retry`);
1545
1792
  return false;
1546
1793
  }
1794
+ const gated = fieldGate(el);
1795
+ if (gated) {
1796
+ console.log(`[browse] Skipping element ${el.i} - ${gated}`);
1797
+ return true;
1798
+ }
1547
1799
  const editor = await detectEditor(page);
1548
1800
  const isCodeEditor = editor !== null;
1549
1801
  if (editor)
@@ -1561,7 +1813,16 @@ async function executeActionHybrid(page, act, elements, charDelayMs) {
1561
1813
  pressKeyOS('ctrl+a');
1562
1814
  await sleep(randomInt(50, 100));
1563
1815
  }
1564
- await typeHumanLike(text, charDelayMs);
1816
+ const guard = new human_input_guard_1.TypingGuard({
1817
+ enabled: fieldSafety.stopOnUserInput,
1818
+ page,
1819
+ label: shortLabel(el),
1820
+ });
1821
+ if (guard)
1822
+ await guard.arm({ isCodeEditor });
1823
+ await typeHumanLike(text, charDelayMs, guard);
1824
+ if (guard.stopped)
1825
+ noteHumanTookOver(el, guard.describeStop());
1565
1826
  return true;
1566
1827
  }
1567
1828
  case 'scroll': {
@@ -1748,13 +2009,21 @@ function hasUnansweredQuestions(elements) {
1748
2009
  const types = els.map((e) => e.type);
1749
2010
  const isRadio = types.some((t) => t === 'radio');
1750
2011
  const isCheckbox = types.some((t) => t === 'checkbox');
1751
- const hasInput = els.some((e) => e.tag === 'input' || e.tag === 'textarea');
2012
+ // Personal fields are the human's to answer: they are never "unanswered"
2013
+ // for the agent, so a `done` from the model with them left empty sticks
2014
+ // instead of triggering the "you must answer everything" retry loop.
2015
+ const inputs = els.filter((e) => (e.tag === 'input' || e.tag === 'textarea') &&
2016
+ e.type !== 'radio' &&
2017
+ e.type !== 'checkbox' &&
2018
+ e.typable !== false);
2019
+ const openInputs = inputs.filter((e) => !(0, personal_fields_1.excludesFromCompleteness)(classificationOf(e)));
2020
+ const hasInput = openInputs.length > 0;
1752
2021
  const checkedCount = els.filter((e) => e.checked).length;
1753
2022
  if (isRadio && checkedCount === 0)
1754
2023
  return true;
1755
2024
  if (isCheckbox && checkedCount < els.length)
1756
2025
  return true;
1757
- if (hasInput && !els.some((e) => e.text && e.tag === 'input'))
2026
+ if (hasInput && !openInputs.some((e) => e.text && e.tag === 'input'))
1758
2027
  return true;
1759
2028
  }
1760
2029
  return false;
@@ -1979,7 +2248,7 @@ async function removeTrailingBrackets(page) {
1979
2248
  console.log(`[browse] Removed ${extra} extra trailing bracket(s)`);
1980
2249
  }
1981
2250
  /** Insert text into ANY code editor — character-by-character typing, no pasting. */
1982
- async function insertIntoCodeEditor(page, selector, text, charDelayMs) {
2251
+ async function insertIntoCodeEditor(page, selector, text, charDelayMs, guard) {
1983
2252
  // Focus the editor
1984
2253
  await focusSelector(page, selector);
1985
2254
  await sleep(100);
@@ -2031,8 +2300,12 @@ async function insertIntoCodeEditor(page, selector, text, charDelayMs) {
2031
2300
  // Type character by character — NO pasting
2032
2301
  const est = Math.round(text.length * charDelayMs / 1000);
2033
2302
  console.log(`[browse] Typing ${text.length} chars (~${est}s est.)...`);
2303
+ if (guard)
2304
+ await guard.arm({ selector, isCodeEditor: true });
2034
2305
  for (let i = 0; i < text.length; i++) {
2035
2306
  const char = text[i];
2307
+ if (guard)
2308
+ await guard.markOwn(char, char === '\n' ? 'Enter' : char === '\t' ? 'Tab' : undefined);
2036
2309
  if (char === '\n') {
2037
2310
  await cdpSend(page, 'Input.dispatchKeyEvent', {
2038
2311
  type: 'keyDown', key: 'Enter', code: 'Enter', text: '\r',
@@ -2070,7 +2343,16 @@ async function insertIntoCodeEditor(page, selector, text, charDelayMs) {
2070
2343
  await cdpSend(page, 'Input.dispatchKeyEvent', upParams);
2071
2344
  }
2072
2345
  await sleep(variableDelay(charDelayMs, char, i, text.length));
2346
+ if (guard) {
2347
+ await guard.tick(text.slice(0, i + 1));
2348
+ if (guard.stopped) {
2349
+ console.log(`[browse] Typing stopped after ${i + 1}/${text.length} chars`);
2350
+ break;
2351
+ }
2352
+ }
2073
2353
  }
2354
+ if (guard)
2355
+ await guard.dispose();
2074
2356
  // Post-typing: detect and remove extra trailing brackets
2075
2357
  await removeTrailingBrackets(page);
2076
2358
  // Re-enable auto-formatting
@@ -2091,7 +2373,7 @@ async function insertIntoCodeEditor(page, selector, text, charDelayMs) {
2091
2373
  console.log(`[browse] Typing complete (${text.length} chars)`);
2092
2374
  }
2093
2375
  /** Insert text into a generic input — click focus + char-by-char typing. */
2094
- async function insertIntoGenericInput(page, el, text, charDelayMs) {
2376
+ async function insertIntoGenericInput(page, el, text, charDelayMs, guard) {
2095
2377
  await cdpSend(page, 'Input.dispatchMouseEvent', {
2096
2378
  type: 'mousePressed', x: el.x, y: el.y, button: 'left', clickCount: 1,
2097
2379
  });
@@ -2101,8 +2383,12 @@ async function insertIntoGenericInput(page, el, text, charDelayMs) {
2101
2383
  await sleep(200);
2102
2384
  await clearEditor(page);
2103
2385
  await sleep(200);
2386
+ if (guard)
2387
+ await guard.arm({ isCodeEditor: false });
2104
2388
  for (let i = 0; i < text.length; i++) {
2105
2389
  const char = text[i];
2390
+ if (guard)
2391
+ await guard.markOwn(char, char === '\n' ? 'Enter' : char === '\t' ? 'Tab' : undefined);
2106
2392
  if (char === '\n') {
2107
2393
  await cdpSend(page, 'Input.dispatchKeyEvent', {
2108
2394
  type: 'keyDown', key: 'Enter', code: 'Enter', text: '\r',
@@ -2140,7 +2426,16 @@ async function insertIntoGenericInput(page, el, text, charDelayMs) {
2140
2426
  await cdpSend(page, 'Input.dispatchKeyEvent', upParams);
2141
2427
  }
2142
2428
  await sleep(variableDelay(charDelayMs, char, i, text.length));
2429
+ if (guard) {
2430
+ await guard.tick(text.slice(0, i + 1));
2431
+ if (guard.stopped) {
2432
+ console.log(`[browse] Typing stopped after ${i + 1}/${text.length} chars`);
2433
+ break;
2434
+ }
2435
+ }
2143
2436
  }
2437
+ if (guard)
2438
+ await guard.dispose();
2144
2439
  }
2145
2440
  /**
2146
2441
  * Extract page context for the LLM: title, URL, headings, errors, editor content.
@@ -2445,7 +2740,10 @@ function extractScatteredActions(str) {
2445
2740
  }
2446
2741
  return actions;
2447
2742
  }
2448
- async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, elements, pageContext, timeoutMs, attempt, maxAttempts, page, lastFingerprint) {
2743
+ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, elements, pageContext, timeoutMs, attempt, maxAttempts, page, lastFingerprint, budget = {
2744
+ maxTokens: DEEP_MAX_TOKENS,
2745
+ deep: false,
2746
+ }) {
2449
2747
  const startTime = Date.now();
2450
2748
  // Format element list grouped by question
2451
2749
  const groups = new Map();
@@ -2492,7 +2790,11 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2492
2790
  for (const el of els) {
2493
2791
  const checked = el.checked ? ' [CHECKED]' : '';
2494
2792
  const visibility = el.inViewport ? '' : ' [OFF-SCREEN]';
2495
- lines.push(` ${el.i}: [${el.type || el.tag}] "${el.text}"${checked}${visibility}`);
2793
+ const skipNote = el.fieldKey && skippedFields.has(el.fieldKey)
2794
+ ? ` [SKIPPED: ${skippedFields.get(el.fieldKey).note}]`
2795
+ : '';
2796
+ const personal = el.typable ? (0, personal_fields_1.promptMarker)(classificationOf(el)) : '';
2797
+ lines.push(` ${el.i}: [${el.type || el.tag}] "${el.text}"${checked}${visibility}${personal}${skipNote}`);
2496
2798
  }
2497
2799
  lines.push('');
2498
2800
  }
@@ -2504,7 +2806,12 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2504
2806
  const body = {
2505
2807
  model,
2506
2808
  messages: [
2507
- { role: 'system', content: SYSTEM_PROMPT },
2809
+ {
2810
+ role: 'system',
2811
+ content: (budget.deep
2812
+ ? SYSTEM_PROMPT.replace(FAST_RULE_0, DEEP_RULE_0)
2813
+ : SYSTEM_PROMPT) + PERSONAL_SAFETY_RULES,
2814
+ },
2508
2815
  {
2509
2816
  role: 'user',
2510
2817
  content: [
@@ -2514,7 +2821,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
2514
2821
  },
2515
2822
  ],
2516
2823
  temperature: 0.1,
2517
- max_tokens: 2048,
2824
+ max_tokens: budget.maxTokens,
2518
2825
  stream: false,
2519
2826
  };
2520
2827
  const controller = new AbortController();
@@ -2667,7 +2974,7 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
2667
2974
  const body = {
2668
2975
  model,
2669
2976
  messages: [
2670
- { role: 'system', content: customSystemPrompt },
2977
+ { role: 'system', content: customSystemPrompt + PERSONAL_SAFETY_RULES },
2671
2978
  {
2672
2979
  role: 'user',
2673
2980
  content: [
@@ -2677,7 +2984,7 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
2677
2984
  },
2678
2985
  ],
2679
2986
  temperature: 0.1,
2680
- max_tokens: 2048,
2987
+ max_tokens: DEEP_MAX_TOKENS,
2681
2988
  stream: false,
2682
2989
  };
2683
2990
  const controller = new AbortController();
@@ -2945,19 +3252,24 @@ function getAdjacentKey(char) {
2945
3252
  * Type text with human-like patterns: variable speed, occasional typos,
2946
3253
  * backspaces, natural pauses. Uses OS-level keyboard (no CDP).
2947
3254
  */
2948
- async function typeHumanLike(text, baseDelayMs = 120) {
3255
+ async function typeHumanLike(text, baseDelayMs = 120, guard) {
3256
+ const send = async (ch, keyName) => {
3257
+ if (guard)
3258
+ await guard.markOwn(ch, keyName);
3259
+ typeCharOS(ch);
3260
+ };
2949
3261
  for (let i = 0; i < text.length; i++) {
2950
3262
  const char = text[i];
2951
3263
  // 1. Occasional typo (2% chance) — then backspace to correct
2952
3264
  if (char !== '\n' && char !== '\t' && Math.random() < 0.02) {
2953
3265
  const wrong = getAdjacentKey(char);
2954
- typeCharOS(wrong);
3266
+ await send(wrong);
2955
3267
  await sleep(randomInt(40, 120));
2956
- typeCharOS('\b'); // backspace
3268
+ await send('\b', 'Backspace'); // backspace
2957
3269
  await sleep(randomInt(25, 70));
2958
3270
  }
2959
3271
  // 2. Type the correct character
2960
- typeCharOS(char);
3272
+ await send(char, char === '\n' ? 'Enter' : char === '\t' ? 'Tab' : undefined);
2961
3273
  // 3. Variable delay based on character type (human rhythm)
2962
3274
  let delay = baseDelayMs + randomInt(-Math.floor(baseDelayMs * 0.3), Math.floor(baseDelayMs * 0.4));
2963
3275
  if (char === ' ')
@@ -2979,7 +3291,16 @@ async function typeHumanLike(text, baseDelayMs = 120) {
2979
3291
  await sleep(randomInt(200, 600));
2980
3292
  }
2981
3293
  await sleep(Math.max(delay, 30));
3294
+ if (guard) {
3295
+ await guard.tick(text.slice(0, i + 1));
3296
+ if (guard.stopped) {
3297
+ console.log(`[browse] Typing stopped after ${i + 1}/${text.length} chars`);
3298
+ break;
3299
+ }
3300
+ }
2982
3301
  }
3302
+ if (guard)
3303
+ await guard.dispose();
2983
3304
  }
2984
3305
  /**
2985
3306
  * Wait for the screen to change by comparing screenshots.
@@ -3042,7 +3363,7 @@ async function callStealthLLM(baseUrl, apiKey, model, screenshotPath, timeoutMs,
3042
3363
  body: JSON.stringify({
3043
3364
  model,
3044
3365
  messages: [
3045
- { role: 'system', content: systemPrompt },
3366
+ { role: 'system', content: systemPrompt + PERSONAL_SAFETY_RULES },
3046
3367
  {
3047
3368
  role: 'user',
3048
3369
  content: [
@@ -3139,9 +3460,41 @@ async function runStealthLoop(baseUrl, apiKey, model, maxActions, intervalMs, ti
3139
3460
  }
3140
3461
  case 'type': {
3141
3462
  if (result.text && result.text.length > 0) {
3463
+ const guard = new human_input_guard_1.TypingGuard({
3464
+ enabled: fieldSafety.stopOnUserInput,
3465
+ osLevel: true,
3466
+ label: result.text.slice(0, 40),
3467
+ });
3468
+ // Stealth has no DOM: use the focused control's own name as the
3469
+ // label for both the personal classifier and the human-skip list.
3470
+ const control = await guard.currentOsControl().catch(() => null);
3471
+ if (control) {
3472
+ if (control.signature && skippedOsFields.has(control.signature)) {
3473
+ console.log(`[stealth] Skipping field - ${skippedOsFields.get(control.signature)}`);
3474
+ actionCount++;
3475
+ break;
3476
+ }
3477
+ if (control.name && fieldSafety.allowPersonal === false) {
3478
+ const cls = (0, personal_fields_1.classifyField)({ label: control.name, ariaLabel: control.name });
3479
+ if ((0, personal_fields_1.isBlocked)(cls, { allowPersonal: fieldSafety.allowPersonal })) {
3480
+ skippedOsFields.set(control.signature, `left to you (${cls.category}: "${control.name.slice(0, 60)}")`);
3481
+ console.log(`[stealth] Not answering a personal question - left to you (${cls.category}: "${control.name.slice(0, 60)}")`);
3482
+ actionCount++;
3483
+ break;
3484
+ }
3485
+ }
3486
+ }
3142
3487
  console.log(`[stealth] Typing ${result.text.length} chars...`);
3143
3488
  await sleep(randomInt(300, 700)); // natural pause before typing
3144
- await typeHumanLike(result.text, charDelayMs);
3489
+ await guard.arm({ isCodeEditor: false });
3490
+ await typeHumanLike(result.text, charDelayMs, guard);
3491
+ if (guard.stopped) {
3492
+ const after = await guard.currentOsControl().catch(() => null);
3493
+ const sig = after?.signature || control?.signature;
3494
+ if (sig)
3495
+ skippedOsFields.set(sig, `you answered this one yourself (${guard.describeStop()})`);
3496
+ console.log(`[stealth] Stopped typing - you answered this one yourself (${guard.describeStop()})`);
3497
+ }
3145
3498
  actionCount++;
3146
3499
  }
3147
3500
  else {