jevway 0.2.0 → 0.3.0

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.
Files changed (41) hide show
  1. package/README.md +37 -2
  2. package/{INSTALL-FOR-AGENTS.md → SETUP.md} +94 -14
  3. package/dist/browser/downloads.js +103 -0
  4. package/dist/browser/frames.js +21 -1
  5. package/dist/browser/snapshot.js +53 -0
  6. package/dist/browser/tab.js +59 -10
  7. package/dist/cli/dispatch.js +2 -0
  8. package/dist/cli/inbox-command.js +199 -0
  9. package/dist/cli/look-command.js +10 -3
  10. package/dist/cli/run-command.js +51 -3
  11. package/dist/daemon/server.js +22 -3
  12. package/dist/inbox/inbox-file.js +43 -0
  13. package/dist/inbox/inbox-registry.js +33 -0
  14. package/dist/inbox/inbox.js +127 -0
  15. package/dist/inbox/mail-api.js +52 -0
  16. package/dist/inbox/message.js +34 -0
  17. package/dist/jev/secrets.js +4 -3
  18. package/dist/mcp/inbox-tools.js +82 -0
  19. package/dist/mcp/instructions.js +11 -2
  20. package/dist/mcp/server.js +15 -8
  21. package/dist/mcp/task-tools.js +24 -8
  22. package/dist/notify/desktop.js +40 -0
  23. package/dist/notify/person-messages.js +50 -0
  24. package/dist/output/layout.js +37 -1
  25. package/dist/output/manifest.js +6 -0
  26. package/dist/output/run-folder.js +44 -2
  27. package/dist/recording/describe-step.js +3 -0
  28. package/dist/session/session-service.js +31 -11
  29. package/dist/setup/skill-command.js +44 -21
  30. package/dist/task/ask-user.js +182 -0
  31. package/dist/task/downloads.js +63 -0
  32. package/dist/task/person-wait.js +104 -0
  33. package/dist/task/present.js +32 -11
  34. package/dist/task/run-task.js +265 -28
  35. package/dist/task/start-task.js +49 -6
  36. package/dist/task/totp.js +124 -0
  37. package/dist/task/upload-files.js +237 -5
  38. package/package.json +2 -2
  39. package/skill/jevway/SKILL.md +5 -2
  40. package/skill/jevway-inbox/SKILL.md +61 -0
  41. package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
@@ -1,3 +1,5 @@
1
+ import { tmpdir } from 'node:os';
2
+ import { join } from 'node:path';
1
3
  import { findBotWall } from '../browser/bot-wall.js';
2
4
  import { PAGE_LEVEL_KINDS, StalePage, TargetUnreachable } from '../browser/tab.js';
3
5
  import { showsTarget } from '../browser/words.js';
@@ -12,8 +14,12 @@ import { deepLink, quoteSpans } from './evidence.js';
12
14
  import { writeCall } from '../output/run-folder.js';
13
15
  import { Recording } from '../recording/recording.js';
14
16
  import { RunStopped, startRunLimit } from './run-limit.js';
15
- import { checkUploadFiles, fileNames } from './upload-files.js';
17
+ import { checkUploadFiles, fetchUploadUrls, fileNames } from './upload-files.js';
18
+ import { checkTotpSecrets, totpMasks, typingCode } from './totp.js';
19
+ import { downloadNote, keepDownload, sizeWords } from './downloads.js';
16
20
  import { isTransientModelError } from '../jev/retry.js';
21
+ import { notificationText, passedText, waitingText } from '../notify/person-messages.js';
22
+ import { ssoProviderOf, stillSigningIn, waitForPersonLoop } from './person-wait.js';
17
23
  // How much of the final page's visible text a summary carries: enough to read a list or a settings section, not a whole page.
18
24
  const PAGE_TEXT_CHARS = 2500;
19
25
  // An agent must know when the text was cut, to read the rest with another call or chrome-devtools.
@@ -45,8 +51,13 @@ const DEFAULTS = {
45
51
  hosts: ANY_HOST,
46
52
  // Prior art (jkudish/jev-browser) waits 8 s; npm's headless challenge never cleared in 50 s, so this bounds patience.
47
53
  wallWaitMs: 8000,
54
+ // A task built without the runner never waits for a person; the runner sets the call's wait (default 120 s when visible).
55
+ waitForPersonSeconds: 0,
48
56
  checks: [],
49
57
  };
58
+ // While waiting for a person: how often the page is read (a full observe, so not every 250 ms) and how often the countdown is told.
59
+ const PERSON_POLL_MS = 1000;
60
+ const PERSON_TICK_MS = 10_000;
50
61
  // How often a bot check is looked at again while it gets the chance to clear itself.
51
62
  const WALL_POLL_MS = 250;
52
63
  // How often a page that has not reacted yet is looked at again while waiting for it to change.
@@ -136,6 +147,8 @@ const BLANK_PAGE = {
136
147
  export class BrowserTask {
137
148
  trace = [];
138
149
  #options;
150
+ /** Whether a person can see the browser and how to tell them; fixed when the session starts. */
151
+ #person;
139
152
  #history = [];
140
153
  #latencies = [];
141
154
  #inputTokens = 0;
@@ -186,6 +199,10 @@ export class BrowserTask {
186
199
  #toggled = new Map();
187
200
  /** Nodes of fields the model chose with no supplied value to type: each is refused once before the caller is asked. */
188
201
  #refusedForValue = new Set();
202
+ /** The caller's authenticator secrets by key: only `decide` (as a marker) and the typing see them. */
203
+ #totp = new Map();
204
+ /** Secrets that are not supplied values, by key: each authenticator secret as given and as base32, every code typed. Kept for the session. */
205
+ #moreSecrets = [];
189
206
  /** Trace entries before this index are already scrubbed of secret values. */
190
207
  #traceScrubbed = 0;
191
208
  /** The page each decision was made on: a `done_when` score counts for the caller's checks only on the page it judged. */
@@ -209,13 +226,22 @@ export class BrowserTask {
209
226
  /** This call's step listener, and how many of the call's steps it was told. */
210
227
  #onStep;
211
228
  #stepsTold = 0;
229
+ #onWait;
212
230
  /** This call's stop signal (caller cancel or time budget); set while a call runs. */
213
231
  #limit;
214
- constructor(tab, ask, { recordDir, output, ...options }) {
232
+ /** URL files this session fetched, by URL: a later call reuses the file. */
233
+ #fetchedUploads = new Map();
234
+ /** Downloads already reported, by guid, and what this call's steps downloaded, for the page text Jev reads. */
235
+ #downloadsSeen = new Set();
236
+ #downloadedThisCall = [];
237
+ constructor(tab, ask, { recordDir, output, totp, person, ...options }) {
215
238
  this.#tab = tab;
239
+ this.#person = person ?? { visible: true };
216
240
  this.#tabs = [tab];
217
241
  this.#ask = ask;
242
+ const authenticators = checkTotpSecrets(totp, options.values);
218
243
  this.#options = { ...DEFAULTS, values: {}, secretKeys: [], ...definedOnly(options), files: checkUploadFiles(options.files) };
244
+ this.#takeTotp(authenticators);
219
245
  this.#output = output ?? (recordDir ? { dir: recordDir, record: true } : undefined);
220
246
  if (this.#output?.record)
221
247
  this.#recording = new Recording(this.#output.dir, (text) => this.#scrub(text));
@@ -231,7 +257,7 @@ export class BrowserTask {
231
257
  }
232
258
  /** Replaces every value the caller marked secret with its marker, in a string or a plain object's strings. */
233
259
  #scrub(value) {
234
- return scrubberFor(this.#options.values, this.#options.secretKeys)(value);
260
+ return scrubberFor(this.#options.values, this.#options.secretKeys, this.#moreSecrets)(value);
235
261
  }
236
262
  // jevway: a page is scrubbed of secrets as soon as the run reads it, so every label, value, history line, page
237
263
  // message and reason is built from scrubbed text before anything cuts it short (a cut secret no longer matches the
@@ -241,7 +267,7 @@ export class BrowserTask {
241
267
  const page = await this.#tab.observe();
242
268
  if (!this.#options.secretKeys.length)
243
269
  return page;
244
- const scrub = scrubberFor(this.#options.values, this.#options.secretKeys);
270
+ const scrub = scrubberFor(this.#options.values, this.#options.secretKeys, this.#moreSecrets);
245
271
  return {
246
272
  ...page,
247
273
  url: scrub(page.url),
@@ -256,9 +282,10 @@ export class BrowserTask {
256
282
  })),
257
283
  };
258
284
  }
259
- /** Reads the session's tab with no model call and no input, its secret values already masked: what `jevway_look` shows. */
260
- look() {
261
- return this.#observe();
285
+ /** Reads the session's tab with no model call and no input, its secret values already masked: what `jevway_look` shows. With `pdf`, the page as a PDF too. */
286
+ async look({ pdf = false } = {}) {
287
+ const page = await this.#observe();
288
+ return pdf ? { ...page, pdf: await this.#tab.pdf() } : page;
262
289
  }
263
290
  /** The tab the session is on now: a tab its clicks opened, once followed, or the one it started on. */
264
291
  get tab() {
@@ -311,9 +338,10 @@ export class BrowserTask {
311
338
  }
312
339
  /** Merge new values, a new goal, narrower hosts (a widening list throws), or a one-time confirmation for the next irreversible click. */
313
340
  update(patch) {
314
- const { confirmIrreversible, confirmTarget, values, secretKeys, files, recordDir: _recordDir, output: _output, allowedHosts, blockedHosts, ...options } = patch;
315
- // First, so a list that would widen the hosts throws before anything else changes.
341
+ const { confirmIrreversible, confirmTarget, values, secretKeys, files, totp, recordDir: _recordDir, output: _output, person: _person, allowedHosts, blockedHosts, ...options } = patch;
342
+ // First, so a list that would widen the hosts, or a bad authenticator secret, throws before anything else changes.
316
343
  const hosts = narrowHosts(this.#options.hosts, { allowed: allowedHosts, blocked: blockedHosts });
344
+ const authenticators = checkTotpSecrets(totp, values);
317
345
  const newGoal = options.goal !== undefined && options.goal !== this.#options.goal;
318
346
  // A new goal may be to flip back what the old one flipped.
319
347
  if (newGoal)
@@ -329,6 +357,7 @@ export class BrowserTask {
329
357
  secretKeys: [...new Set([...this.#options.secretKeys, ...(secretKeys ?? [])])],
330
358
  files: { ...this.#options.files, ...checkUploadFiles(files) },
331
359
  };
360
+ this.#takeTotp(authenticators, values);
332
361
  if (!confirmIrreversible)
333
362
  return;
334
363
  // A confirmation answers the question jevway asked, so a flag carried onto a later call allows nothing (an unused earlier one stands).
@@ -340,13 +369,40 @@ export class BrowserTask {
340
369
  else if (!this.#confirmedCommit)
341
370
  this.#confirmIgnored = this.#lastStatus;
342
371
  }
372
+ // The newest plain value or authenticator secret for a key replaces the older one; either stays secret for the session.
373
+ #takeTotp(authenticators, values = {}) {
374
+ for (const key of Object.keys(values))
375
+ this.#totp.delete(key);
376
+ for (const [key, secret] of authenticators)
377
+ this.#totp.set(key, secret);
378
+ this.#moreSecrets.push(...totpMasks(authenticators));
379
+ if (!authenticators.size)
380
+ return;
381
+ this.#options.values = Object.fromEntries(Object.entries(this.#options.values).filter(([key]) => !authenticators.has(key)));
382
+ this.#options.secretKeys = [...new Set([...this.#options.secretKeys, ...authenticators.keys()])];
383
+ }
384
+ // An authenticator key types the code current now (the next one when its period is about to end), masked before it is typed.
385
+ async #textFor(key, values) {
386
+ const secret = this.#totp.get(key);
387
+ if (!secret)
388
+ return values[key];
389
+ const code = await typingCode(secret, { now: Date.now, pause: (ms) => this.#limit?.pause(ms) ?? Promise.resolve() });
390
+ this.#moreSecrets.push([key, code]);
391
+ return code;
392
+ }
343
393
  /**
344
394
  * Runs until done or a hand-back. `signal` is the caller's cancel (the MCP request's): the run stops at its next
345
395
  * check, `blocked` "Cancelled by the caller"; an input already under way finishes first. `onStep` hears each step of
346
- * this call (numbered from 1) once the run moves past it, with the page's URL then; a listener that throws is ignored.
396
+ * this call (numbered from 1) once the run moves past it, with the page's URL then; `onWait` the countdown while it
397
+ * waits for a person at a wall. A listener that throws is ignored.
347
398
  */
348
- async run({ signal, onStep } = {}) {
399
+ async run({ signal, onStep, onWait } = {}) {
400
+ // URL files are fetched before the run reads a page; a refused one fails the call naming its key.
401
+ const uploads = this.#output ? join(this.#output.dir, 'uploads') : join(tmpdir(), `jevway-uploads-${process.pid}`);
402
+ this.#options.files = await fetchUploadUrls(this.#options.files, { dir: uploads, hosts: this.#options.hosts, known: this.#fetchedUploads, signal });
403
+ this.#downloadedThisCall = [];
349
404
  this.#onStep = onStep;
405
+ this.#onWait = onWait;
350
406
  this.#stepsTold = 0;
351
407
  // The recording is for people: a browser failure while recording must not change or hang the run's outcome.
352
408
  await this.#recording?.start(this.#tab).catch(() => undefined);
@@ -366,6 +422,7 @@ export class BrowserTask {
366
422
  this.trace[this.#traceScrubbed] = this.#scrub(this.trace[this.#traceScrubbed]);
367
423
  const summary = this.#scrub(settled);
368
424
  this.#onStep = undefined;
425
+ this.#onWait = undefined;
369
426
  await this.#recording?.finish(this.#tab, summary, { goal: this.#options.goal, doneWhen: this.#options.doneWhen }).catch(() => undefined);
370
427
  if (this.#output)
371
428
  await this.#writeCall(this.#output.dir, summary);
@@ -390,6 +447,61 @@ export class BrowserTask {
390
447
  const scale = Math.min(1, END_PAGE_WIDTH / (width * ratio));
391
448
  return Buffer.from(await this.#tab.screenshot({ format: 'jpeg', quality: 70, scale }), 'base64');
392
449
  }
450
+ /** Tells the wait listener the countdown; like a step listener, it only hears. */
451
+ #tellWait(message, secondsLeft, url) {
452
+ try {
453
+ this.#onWait?.({ message: this.#scrub(message), seconds_left: secondsLeft }, this.#scrub(url));
454
+ }
455
+ catch {
456
+ // The caller's progress line never decides how the run goes.
457
+ }
458
+ }
459
+ // jevway: with the browser in view, a person can get past a wall the run never touches. The tab comes forward, one
460
+ // notification says what to do, and the page is read (no model, no input) until the wall is gone or the time is up.
461
+ async #waitForPerson(wall, page, { isWall, clearReads, limit }) {
462
+ const waitMs = this.#person.visible ? this.#options.waitForPersonSeconds * 1000 : 0;
463
+ if (waitMs <= 0 || limit.stopped())
464
+ return { outcome: 'off', page };
465
+ const tab = this.#tab;
466
+ for (const [method, params] of [['Page.bringToFront', {}], ['Target.activateTarget', { targetId: tab.targetId }]]) {
467
+ try {
468
+ await tab.send(method, params);
469
+ }
470
+ catch {
471
+ // Best effort: an old Chrome or a closed window still gets the notification.
472
+ }
473
+ }
474
+ try {
475
+ this.#person.notify?.(notificationText(wall));
476
+ }
477
+ catch {
478
+ // A notifier only hears.
479
+ }
480
+ const { outcome, page: read } = await waitForPersonLoop({
481
+ waitMs,
482
+ pollMs: this.#person.pollMs ?? PERSON_POLL_MS,
483
+ tickMs: this.#person.tickMs ?? PERSON_TICK_MS,
484
+ clearReads,
485
+ read: async () => {
486
+ // A tab the page opens meanwhile is the person's (a "Sign in with Google" popup): never followed.
487
+ tab.takeOpened();
488
+ if (tab.closed)
489
+ return undefined;
490
+ return this.#observe().catch((error) => {
491
+ if (error instanceof StalePage)
492
+ return page;
493
+ throw error;
494
+ });
495
+ },
496
+ isWall,
497
+ pause: limit.pause,
498
+ stopped: () => Boolean(limit.stopped()),
499
+ tell: (secondsLeft) => this.#tellWait(waitingText(wall, secondsLeft), secondsLeft, page.url),
500
+ });
501
+ if (outcome === 'passed')
502
+ this.#history.push({ operation: 'WAIT_FOR_PERSON', element: passedText(wall), page_changed: true, confidence: 1 });
503
+ return { outcome, page: read ?? page };
504
+ }
393
505
  /** Tells the step listener about this call's steps it has not heard yet; the page's URL is the one the run is on now. */
394
506
  #tellSteps(firstStep, url) {
395
507
  if (!this.#onStep)
@@ -490,8 +602,8 @@ export class BrowserTask {
490
602
  const now = await this.#observe().catch(() => undefined);
491
603
  if (now && (await findBotWall(this.#tab, now))) {
492
604
  page = now;
493
- const { wall } = await wallAfterWait();
494
- return wall ? stopAtWall(wall, decision) : undefined;
605
+ const { wall, waited } = await wallAfterWait();
606
+ return wall ? stopAtWall(wall, decision, waited) : undefined;
495
607
  }
496
608
  }
497
609
  return stop('done', reason, decision);
@@ -507,8 +619,19 @@ export class BrowserTask {
507
619
  return stop(status, reason, decision, { overridable: true });
508
620
  };
509
621
  // jevway: a bot check offers nothing to act on, and the run never tries to get past one. It gets wallWaitMs to clear
510
- // itself (the page read again meanwhile), once per call: one that comes back after clearing ends the run at once.
622
+ // itself (the page read again meanwhile), then, in a visible browser, a person gets waitForPersonSeconds to pass it;
623
+ // once per call: one that comes back after clearing ends the run at once.
511
624
  let wallWaited = false;
625
+ // Each kind of wall waits for a person once per call.
626
+ const personWaited = new Set();
627
+ const waitForPerson = async (wall, isWall, clearReads) => {
628
+ if (personWaited.has(wall.kind))
629
+ return 'off';
630
+ personWaited.add(wall.kind);
631
+ const waited = await this.#waitForPerson(wall, page, { isWall, clearReads, limit });
632
+ page = waited.page;
633
+ return waited.outcome;
634
+ };
512
635
  const wallAfterWait = async () => {
513
636
  let wall = await findBotWall(this.#tab, page);
514
637
  if (!wall)
@@ -529,14 +652,24 @@ export class BrowserTask {
529
652
  });
530
653
  wall = await findBotWall(this.#tab, page);
531
654
  }
532
- return { wall, cleared: !wall };
655
+ if (!wall || limit.stopped())
656
+ return { wall, cleared: !wall };
657
+ const outcome = await waitForPerson({ kind: 'bot_check', check: wall.name }, async (now) => Boolean(await findBotWall(this.#tab, now)), 1);
658
+ // A tab that went away is the loop's to handle, as after any step.
659
+ if (outcome === 'passed' || outcome === 'gone')
660
+ return { cleared: true, byPerson: true };
661
+ return { wall, cleared: false, ...(outcome === 'ran_out' && { waited: this.#options.waitForPersonSeconds }) };
533
662
  };
534
- const stopAtWall = (wall, decision) => {
663
+ const stopAtWall = (wall, decision, waited) => {
535
664
  // A cancel or the time budget that cut the wait short is the reason the run ended.
536
665
  const halt = limit.stopped();
537
666
  if (halt)
538
667
  return stop(halt.status, halt.reason, decision);
539
- return { ...stop('blocked', wallReason(wall), decision), wall: { kind: 'bot_check', origin: originOf(page.url), name: wall.provider } };
668
+ const { visible } = this.#person;
669
+ return {
670
+ ...stop('blocked', wallReason(wall, { visible, waited }), decision),
671
+ wall: { kind: 'bot_check', origin: originOf(page.url), name: wall.provider, person_can_act: visible, ...(waited !== undefined && { waited_seconds: waited }) },
672
+ };
540
673
  };
541
674
  // Browser and model errors end the run as `blocked`: the calling agent keeps the session and decides what next.
542
675
  try {
@@ -562,9 +695,31 @@ export class BrowserTask {
562
695
  for (const tab of this.#tabs)
563
696
  tab.disarmConfirm();
564
697
  // Checked before a decision is spent: a challenge page has nothing for Jev to pick.
565
- const { wall } = await wallAfterWait();
698
+ const { wall, waited, byPerson } = await wallAfterWait();
566
699
  if (wall)
567
- return stopAtWall(wall);
700
+ return stopAtWall(wall, undefined, waited);
701
+ // A person passed it (or its tab went away): the checks above run again on the page now.
702
+ if (byPerson)
703
+ continue;
704
+ // An identity provider's sign-in for another site is the person's: with a visible browser it waits for them.
705
+ const provider = ssoProviderOf(page.url);
706
+ if (provider) {
707
+ const origin = originOf(page.url);
708
+ const outcome = await waitForPerson({ kind: 'sso', provider }, (now) => Boolean(ssoProviderOf(now.url)), 2);
709
+ if (outcome === 'passed' || outcome === 'gone')
710
+ continue;
711
+ if (outcome === 'stopped') {
712
+ const halt = limit.stopped();
713
+ return stop(halt.status, halt.reason);
714
+ }
715
+ if (outcome === 'ran_out') {
716
+ const waited = this.#options.waitForPersonSeconds;
717
+ return {
718
+ ...stop('blocked', ssoReason(provider, origin, waited)),
719
+ wall: { kind: 'sign_in', origin, name: provider.toLowerCase(), person_can_act: true, waited_seconds: waited },
720
+ };
721
+ }
722
+ }
568
723
  if (++decisions > maxSteps * 2)
569
724
  return stop('step_budget', `Made ${maxSteps * 2} decisions without finishing`);
570
725
  let decision;
@@ -626,9 +781,18 @@ export class BrowserTask {
626
781
  continue;
627
782
  }
628
783
  if (signIn) {
784
+ const wallPage = page;
785
+ const outcome = await waitForPerson({ kind: 'sign_in', host: hostOf(page.url) }, (now) => stillSigningIn(now, wallPage), 2);
786
+ if (outcome === 'passed' || outcome === 'gone')
787
+ continue;
788
+ const halt = limit.stopped();
789
+ if (halt)
790
+ return stop(halt.status, halt.reason, decision);
791
+ const { visible } = this.#person;
792
+ const waited = outcome === 'ran_out' ? this.#options.waitForPersonSeconds : undefined;
629
793
  return {
630
- ...stop('blocked', `The page asks to sign in at ${signIn.origin} and no supplied value fits its fields; sign in there (or pass the values), then call again.`, decision),
631
- wall: signIn,
794
+ ...stop('blocked', signInReason(signIn.origin, { visible, waited }), decision),
795
+ wall: { ...signIn, person_can_act: visible, ...(waited !== undefined && { waited_seconds: waited }) },
632
796
  };
633
797
  }
634
798
  return stop('blocked', error, decision);
@@ -748,7 +912,7 @@ export class BrowserTask {
748
912
  continue;
749
913
  }
750
914
  const action = decision.action;
751
- const text = decision.valueKey ? values[decision.valueKey] : undefined;
915
+ const text = decision.valueKey ? await this.#textFor(decision.valueKey, values) : undefined;
752
916
  let element;
753
917
  // jevway: the dialog a confirmed click opens is the same action asking again; any other dialog still gets the answer that changes nothing.
754
918
  if (isRiskyClick(decision) && this.#confirmedCommit)
@@ -930,6 +1094,7 @@ export class BrowserTask {
930
1094
  if (decision.operation === 'CLICK' && decision.via !== 'disclosure' && action.expanded === 'false' && page.actions.some((a) => a.node === action.node && a.expanded === 'true')) {
931
1095
  this.#openedByRun.add(action.node);
932
1096
  }
1097
+ await this.#reportDownloads(firstStep);
933
1098
  const repeatedDialog = this.#reportDialogs(firstStep, decision.operation === 'CLICK' ? { action, path: pathBefore, unsaved: unsavedBefore } : undefined);
934
1099
  this.#tab.disarmConfirm();
935
1100
  if (repeatedDialog)
@@ -1101,6 +1266,34 @@ export class BrowserTask {
1101
1266
  this.#history[last] = { ...this.#history[last], not_executed: saveFirst };
1102
1267
  return repeated;
1103
1268
  }
1269
+ // jevway: the downloads the last action started, once finished (30 s at most), kept in the run folder and noted on its step.
1270
+ async #reportDownloads(firstStep) {
1271
+ const notes = [];
1272
+ for (const tab of this.#tabs) {
1273
+ for (const download of await tab.downloadsAfterInput()) {
1274
+ // A tab this one opened reports its own downloads, which its opener claimed too.
1275
+ if (this.#downloadsSeen.has(download.guid))
1276
+ continue;
1277
+ this.#downloadsSeen.add(download.guid);
1278
+ const named = { ...download, name: this.#scrub(download.name) };
1279
+ let kept;
1280
+ try {
1281
+ kept = keepDownload(named, this.#output?.dir);
1282
+ }
1283
+ catch {
1284
+ kept = { name: named.name, bytes: named.bytes, state: named.state, ...(named.filePath && { path: named.filePath }) };
1285
+ }
1286
+ notes.push(downloadNote(kept));
1287
+ if (kept.state === 'completed')
1288
+ this.#downloadedThisCall.push(`${kept.name} (${sizeWords(kept.bytes)})`);
1289
+ }
1290
+ }
1291
+ const last = this.#history.length - 1;
1292
+ if (notes.length && last >= firstStep) {
1293
+ const entry = this.#history[last];
1294
+ this.#history[last] = { ...entry, element: `${entry.element} ${notes.map((note) => `(${note})`).join(' ')}` };
1295
+ }
1296
+ }
1104
1297
  // What the run left unsaved on the page a click was made on: the last field typed there, else the control last ticked.
1105
1298
  #unsavedOn(path, ticked) {
1106
1299
  const typed = this.#typedFields.filter((field) => field.path === path).at(-1);
@@ -1188,7 +1381,8 @@ export class BrowserTask {
1188
1381
  const decision = await decide(ask, {
1189
1382
  page: this.#withSaid(this.#withKeys(page)),
1190
1383
  goal,
1191
- values,
1384
+ // An authenticator key is offered like a secret value: Jev sees its marker, never the secret.
1385
+ values: { ...values, ...Object.fromEntries([...this.#totp].map(([key, secret]) => [key, secret.base32])) },
1192
1386
  secretKeys,
1193
1387
  files: fileNames(this.#options.files),
1194
1388
  history: this.#history.slice(-10),
@@ -1238,12 +1432,17 @@ export class BrowserTask {
1238
1432
  // jevway: what the page said after the last submit is the first line of the text Jev reads. The done check judges the current
1239
1433
  // page's text only, so a "saved" message that scrolled away (or sits above the viewport) is no evidence unless the text carries it.
1240
1434
  // It stays while the run is on the path where the message appeared; the caller's page_text is never changed.
1435
+ // A download shows nothing on the page, so what this call downloaded leads the text too.
1241
1436
  #withSaid(page) {
1242
1437
  if (this.#said && this.#said.path !== pathOf(page.url))
1243
1438
  this.#said = undefined;
1244
- if (!this.#said)
1439
+ const lines = [
1440
+ ...(this.#downloadedThisCall.length ? [`[Downloaded this call] ${this.#downloadedThisCall.join(', ')}`] : []),
1441
+ ...(this.#said ? [`[Page message after "${this.#said.label}"] ${this.#said.text}`] : []),
1442
+ ];
1443
+ if (!lines.length)
1245
1444
  return page;
1246
- return { ...page, text: `[Page message after "${this.#said.label}"] ${this.#said.text}\n${page.text}` };
1445
+ return { ...page, text: `${lines.join('\n')}\n${page.text}` };
1247
1446
  }
1248
1447
  // The lines the page now shows that this run never saw on its path. The previous message of a submit doesn't count as seen, so a repeated one is quoted again.
1249
1448
  #saidAfter(label, page, textBefore) {
@@ -1537,12 +1736,25 @@ const controlOf = (decision, page) => ({
1537
1736
  label: decision.operation === 'CLICK_AT' ? '' : bareLabel(decision.action?.label ?? ''),
1538
1737
  coordinate: decision.operation === 'CLICK_AT',
1539
1738
  });
1739
+ // How many of a field's choices a needs_input lists, and how much of each: page text, so kept short.
1740
+ const FIELD_CHOICES = 10;
1741
+ const FIELD_CHOICE_CHARS = 80;
1742
+ // The choices the page lists for a field: a select of the same name, or the option rows of its open list (an expanded combobox).
1743
+ const choicesFor = (field, page) => {
1744
+ const prefix = `${field.label} → `;
1745
+ const selectOptions = page.actions.filter((action) => action.kind === 'select' && action.label.startsWith(prefix)).map((action) => action.label.slice(prefix.length));
1746
+ const openRows = field.expanded === 'true' ? page.actions.filter((action) => action.kind === 'click' && action.role === 'option').map((action) => bareLabel(action.label)) : [];
1747
+ return [...new Set([...selectOptions, ...openRows].map((label) => label.trim().slice(0, FIELD_CHOICE_CHARS)).filter(Boolean))].slice(0, FIELD_CHOICES);
1748
+ };
1540
1749
  // The fields a needs_input wants: the one Jev chose to type into, or every empty field a BLOCKED found no value for, in page order.
1541
1750
  const missingFields = (decision, page) => {
1542
1751
  const fields = decision.operation === 'TYPE_TEXT' && decision.action
1543
1752
  ? [decision.action]
1544
1753
  : decision.fieldsWithoutValue.map((label) => page.actions.find((action) => action.kind === 'fill' && action.label === label) ?? { label });
1545
- return fields.map((field) => ({ label: bareLabel(field.label), ...(field.role && { role: field.role }), path: pathnameOf(page.url) }));
1754
+ return fields.map((field) => {
1755
+ const options = choicesFor(field, page);
1756
+ return { label: bareLabel(field.label), ...(field.role && { role: field.role }), path: pathnameOf(page.url), ...(options.length && { options }) };
1757
+ });
1546
1758
  };
1547
1759
  const sameControl = (a, b) => a.path === b.path && a.label === b.label && a.coordinate === b.coordinate;
1548
1760
  const pathnameOf = (url) => {
@@ -1664,7 +1876,32 @@ const round = (value) => Math.round(value * 1000) / 1000;
1664
1876
  class ModelCallError extends Error {
1665
1877
  }
1666
1878
  const messageOf = (error) => (error instanceof Error ? error.message : String(error));
1667
- // The hand-back for a bot wall: what it is, what made it one, and who can pass it.
1668
- const wallReason = (wall) => `The page is a bot check (${wall.name}; seen: ${wall.evidence.map((e) => (e.startsWith('title ') ? e : `"${e}"`)).join(', ')}) that did not clear by itself; jevway never tries to get past one. A person can pass it in this tab, then continue the session.`;
1879
+ // The hand-back for a bot wall: what it is, what made it one, whether a person was waited for, and who can pass it.
1880
+ const wallReason = (wall, { visible, waited }) => {
1881
+ const seen = wall.evidence.map((e) => (e.startsWith('title ') ? e : `"${e}"`)).join(', ');
1882
+ const wait = waited === undefined ? '' : `, and nobody passed it in the ${waited} s jevway waited with the tab in front`;
1883
+ const who = visible
1884
+ ? 'A person can pass it in this tab, then continue the session.'
1885
+ : 'This browser is headless, so nobody can pass it here: a visible browser is needed (JEVWAY_BROWSER=auto or launch).';
1886
+ return `The page is a bot check (${wall.name}; seen: ${seen}) that did not clear by itself${wait}; jevway never tries to get past one. ${who}`;
1887
+ };
1888
+ // The hand-back for a sign-in wall no supplied value passes.
1889
+ const signInReason = (origin, { visible, waited }) => {
1890
+ const wait = waited === undefined ? '' : `, and nobody signed in during the ${waited} s jevway waited with the tab in front`;
1891
+ const what = visible
1892
+ ? 'sign in there (or pass the values), then call again.'
1893
+ : 'pass the values, or sign in with a visible browser (JEVWAY_BROWSER=auto or launch; this one is headless), then call again.';
1894
+ return `The page asks to sign in at ${origin} and no supplied value fits its fields${wait}; ${what}`;
1895
+ };
1896
+ // The hand-back for an SSO sign-in nobody finished while the run waited.
1897
+ const ssoReason = (provider, origin, waited) => `The page asks to sign in with ${provider} at ${origin} for another site, and nobody finished it during the ${waited} s jevway waited with the tab in front; sign in there, then call again.`;
1898
+ const hostOf = (url) => {
1899
+ try {
1900
+ return new URL(url).host;
1901
+ }
1902
+ catch {
1903
+ return url;
1904
+ }
1905
+ };
1669
1906
  // Anything that is not a model error came from the tab (CDP, page never settling, an unconfirmed action).
1670
1907
  const describeError = (error) => `${error instanceof ModelCallError ? 'Model' : 'Browser'} error: ${messageOf(error).slice(0, 200)}`;
@@ -1,4 +1,5 @@
1
1
  import { connectChrome } from '../browser/chrome.js';
2
+ import { dropStagedDownloads, useDownloads } from '../browser/downloads.js';
2
3
  import { Tab } from '../browser/tab.js';
3
4
  import { createAsk, profileOptions, resolveModel } from '../jev/model.js';
4
5
  import { replayMode } from '../jev/replay.js';
@@ -7,6 +8,7 @@ import { missingKeyMessage } from '../setup/env-file.js';
7
8
  import { hostsFromEnv, narrowHosts, outsideHosts } from './hosts.js';
8
9
  import { BrowserTask } from './run-task.js';
9
10
  import { retryingAsk } from '../jev/retry.js';
11
+ import { desktopNotifier } from '../notify/desktop.js';
10
12
  /**
11
13
  * Connects a browser once and starts tasks on tabs inside it. Tasks and journeys need the chosen model's key:
12
14
  * `TYPESAFE_API_KEY` for Jev (default), `OPENROUTER_API_KEY` for Clef-omni. Without it the runner is still made, so an
@@ -30,7 +32,12 @@ export const createRunner = (source, choice = {}) => {
30
32
  return (client ??= createAsk(profile));
31
33
  };
32
34
  let chrome;
33
- const browser = () => (chrome ??= connectChrome(source));
35
+ // Downloads are set up once per connection: staged in jevway's own browser, Chrome's own behaviour in the person's.
36
+ const browser = () => (chrome ??= connectChrome(source).then(async (handle) => {
37
+ await useDownloads(handle.cdp, source).catch(() => undefined);
38
+ return handle;
39
+ }));
40
+ let shown;
34
41
  return {
35
42
  /** Throws the missing-key message, so the CLI can report it as a usage error before any browser starts. */
36
43
  requireKey() {
@@ -46,17 +53,23 @@ export const createRunner = (source, choice = {}) => {
46
53
  if (!url && !tab)
47
54
  throw new Error('Pass `url` to open a new tab or `tab` to reuse an open one.');
48
55
  const page = url ? await Tab.open(cdp, url) : await Tab.find(cdp, tab);
56
+ const visible = await (shown ??= browserShown(source, cdp));
49
57
  const task = new BrowserTask(page, decider, {
50
58
  ...profileOptions(profile),
51
59
  ...definedOnly(options),
52
60
  hosts,
53
61
  screenshots: options.screenshots ?? screenshots,
62
+ waitForPersonSeconds: personWaitSeconds(options.waitForPersonSeconds),
63
+ person: { visible, notify: desktopNotifier() },
54
64
  });
55
65
  task.update({ confirmIrreversible, confirmTarget });
56
66
  return task;
57
67
  },
58
- /** Reads a page with no model call and no input: an open tab by URL part, or a URL in a background tab closed again. */
59
- async look({ url, tab }) {
68
+ /**
69
+ * Reads a page with no model call and no input: an open tab by URL part, or a URL in a background tab closed again.
70
+ * With `pdf`, the page printed as a PDF comes with it.
71
+ */
72
+ async look({ url, tab, pdf = false }) {
60
73
  if (!url && !tab)
61
74
  throw new Error('Pass `url` to open a page or `tab` to read an open one.');
62
75
  if (url)
@@ -64,7 +77,8 @@ export const createRunner = (source, choice = {}) => {
64
77
  const { cdp } = await browser();
65
78
  const page = url ? await Tab.open(cdp, url) : await Tab.find(cdp, tab);
66
79
  try {
67
- return await page.observe();
80
+ const state = await page.observe();
81
+ return pdf ? { ...state, pdf: await page.pdf() } : state;
68
82
  }
69
83
  finally {
70
84
  if (url)
@@ -89,14 +103,43 @@ export const createRunner = (source, choice = {}) => {
89
103
  });
90
104
  },
91
105
  async close() {
92
- if (chrome)
93
- await (await chrome).close();
106
+ if (chrome) {
107
+ const handle = await chrome;
108
+ await handle.close();
109
+ dropStagedDownloads(handle.cdp);
110
+ }
94
111
  chrome = undefined;
112
+ shown = undefined;
95
113
  },
96
114
  };
97
115
  };
98
116
  // Waits before each retry of a journey's model call after a busy or flaky API answer.
99
117
  const JOURNEY_RETRY_MS = [1000, 3000];
118
+ // Waits for a person at a wall this long, in seconds, when the call names no wait and JEVWAY_PERSON_WAIT_SECONDS is unset.
119
+ const PERSON_WAIT_SECONDS = 120;
120
+ /**
121
+ * The call's wait for a person, else `JEVWAY_PERSON_WAIT_SECONDS` (whole seconds), else 120. A headless browser waits 0
122
+ * whatever this says (the task knows whether a person can see it).
123
+ *
124
+ * @example
125
+ * personWaitSeconds(undefined, { JEVWAY_PERSON_WAIT_SECONDS: '60' }) // 60
126
+ */
127
+ export const personWaitSeconds = (call, env = process.env) => {
128
+ const raw = env.JEVWAY_PERSON_WAIT_SECONDS?.trim();
129
+ return call ?? (raw && /^\d+$/.test(raw) ? Number(raw) : PERSON_WAIT_SECONDS);
130
+ };
131
+ /** Whether a person can see this browser: never `launch-headless`, nor a headless Chrome behind a port or `auto`. */
132
+ const browserShown = async (source, cdp) => {
133
+ if (source === 'launch-headless')
134
+ return false;
135
+ try {
136
+ const { userAgent } = await cdp.send('Browser.getVersion');
137
+ return !/HeadlessChrome/.test(String(userAgent));
138
+ }
139
+ catch {
140
+ return true;
141
+ }
142
+ };
100
143
  /** Throws before a page outside the host rules is opened. */
101
144
  const refuseOutside = (url, hosts) => {
102
145
  const outside = outsideHosts(url, hosts);