captchakraken 2.8.0 → 2.9.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.
@@ -1,26 +1,24 @@
1
1
  /**
2
2
  * How the JS client hands a solve request to the bundled Python CLI.
3
3
  *
4
- * The API key used to travel as a POSITIONAL ARGUMENT, and the whole command
5
- * line was then echoed:
4
+ * Two rules govern this module, and both exist because the alternative is
5
+ * unsafe rather than merely untidy:
6
6
  *
7
- * console.log(`Executing CaptchaKraken CLI: ${command}`);
8
- * // Executing CaptchaKraken CLI: python -m captchakraken.cli "shot.png" \
9
- * // captcha-v12 captchaKrakenApi b8978fa3392... --puzzle-source=hcaptcha
7
+ * 1. The API key travels in the ENVIRONMENT, never in argv and never to stdout.
8
+ * Argv is world-readable on Linux — any local user can read
9
+ * `/proc/<pid>/cmdline` while a solve runs, and `ps` shows it — and anything
10
+ * printed alongside the command lands in CI logs and terminal scrollback.
11
+ * An environment block is per-process and not world-readable.
10
12
  *
11
- * Two leaks from one mistake. Argv is world-readable on Linux — any local user
12
- * can read `/proc/<pid>/cmdline` while the solve runs, and `ps` shows it — and
13
- * the key was additionally written to stdout, so it landed in CI logs, terminal
14
- * scrollback, and anything scraping driver output.
15
- *
16
- * Secrets go through the environment instead, which is per-process and not
17
- * world-readable, and the command is built as an ARGV ARRAY for `execFile`
18
- * rather than a joined string for a shell, so a path with a space or a quote
19
- * cannot reshape the command.
13
+ * 2. The command is built as an ARGV ARRAY for `execFile`, not a joined string
14
+ * for a shell, so a screenshot path containing a space or a quote cannot
15
+ * reshape the command.
20
16
  */
21
17
  /** Env var the CLI reads the bearer token from. */
22
18
  export declare const API_KEY_ENV = "CAPTCHA_KRAKEN_API_KEY";
23
- /** Historical positional placeholder. The CLI still accepts the slot. */
19
+ /** Env var carrying the re-ask level for a board the vendor already refused. */
20
+ export declare const RESAMPLE_LEVEL_ENV = "CAPTCHA_RESAMPLE_LEVEL";
21
+ /** Positional placeholder the CLI still accepts in this slot. */
24
22
  export declare const API_PROVIDER = "captchaKrakenApi";
25
23
  export interface SolveInvocation {
26
24
  imagePath: string;
@@ -37,7 +35,7 @@ export interface SolveInvocation {
37
35
  */
38
36
  export declare function buildSolveArgs(invocation: SolveInvocation): string[];
39
37
  /** Base env plus the credential, when there is one. */
40
- export declare function solveEnv(base: NodeJS.ProcessEnv, apiKey?: string): NodeJS.ProcessEnv;
38
+ export declare function solveEnv(base: NodeJS.ProcessEnv, apiKey?: string, resampleLevel?: number): NodeJS.ProcessEnv;
41
39
  /**
42
40
  * A log line for a solve invocation, with any credential removed.
43
41
  *
@@ -2,31 +2,29 @@
2
2
  /**
3
3
  * How the JS client hands a solve request to the bundled Python CLI.
4
4
  *
5
- * The API key used to travel as a POSITIONAL ARGUMENT, and the whole command
6
- * line was then echoed:
5
+ * Two rules govern this module, and both exist because the alternative is
6
+ * unsafe rather than merely untidy:
7
7
  *
8
- * console.log(`Executing CaptchaKraken CLI: ${command}`);
9
- * // Executing CaptchaKraken CLI: python -m captchakraken.cli "shot.png" \
10
- * // captcha-v12 captchaKrakenApi b8978fa3392... --puzzle-source=hcaptcha
8
+ * 1. The API key travels in the ENVIRONMENT, never in argv and never to stdout.
9
+ * Argv is world-readable on Linux — any local user can read
10
+ * `/proc/<pid>/cmdline` while a solve runs, and `ps` shows it — and anything
11
+ * printed alongside the command lands in CI logs and terminal scrollback.
12
+ * An environment block is per-process and not world-readable.
11
13
  *
12
- * Two leaks from one mistake. Argv is world-readable on Linux — any local user
13
- * can read `/proc/<pid>/cmdline` while the solve runs, and `ps` shows it — and
14
- * the key was additionally written to stdout, so it landed in CI logs, terminal
15
- * scrollback, and anything scraping driver output.
16
- *
17
- * Secrets go through the environment instead, which is per-process and not
18
- * world-readable, and the command is built as an ARGV ARRAY for `execFile`
19
- * rather than a joined string for a shell, so a path with a space or a quote
20
- * cannot reshape the command.
14
+ * 2. The command is built as an ARGV ARRAY for `execFile`, not a joined string
15
+ * for a shell, so a screenshot path containing a space or a quote cannot
16
+ * reshape the command.
21
17
  */
22
18
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.API_PROVIDER = exports.API_KEY_ENV = void 0;
19
+ exports.API_PROVIDER = exports.RESAMPLE_LEVEL_ENV = exports.API_KEY_ENV = void 0;
24
20
  exports.buildSolveArgs = buildSolveArgs;
25
21
  exports.solveEnv = solveEnv;
26
22
  exports.redactCommand = redactCommand;
27
23
  /** Env var the CLI reads the bearer token from. */
28
24
  exports.API_KEY_ENV = 'CAPTCHA_KRAKEN_API_KEY';
29
- /** Historical positional placeholder. The CLI still accepts the slot. */
25
+ /** Env var carrying the re-ask level for a board the vendor already refused. */
26
+ exports.RESAMPLE_LEVEL_ENV = 'CAPTCHA_RESAMPLE_LEVEL';
27
+ /** Positional placeholder the CLI still accepts in this slot. */
30
28
  exports.API_PROVIDER = 'captchaKrakenApi';
31
29
  /**
32
30
  * The argv for one solve. No shell, no quoting, and NO CREDENTIAL — the key is
@@ -53,10 +51,20 @@ function buildSolveArgs(invocation) {
53
51
  return args;
54
52
  }
55
53
  /** Base env plus the credential, when there is one. */
56
- function solveEnv(base, apiKey) {
57
- if (!apiKey)
58
- return base;
59
- return { ...base, [exports.API_KEY_ENV]: apiKey };
54
+ function solveEnv(base, apiKey, resampleLevel) {
55
+ const out = apiKey ? { ...base, [exports.API_KEY_ENV]: apiKey } : { ...base };
56
+ // How many times THIS board has already been read and refused. The CLI is a
57
+ // fresh process per round, so it cannot know; and without it a re-ask is the
58
+ // same greedy arithmetic on the same pixels and returns the same answer,
59
+ // which is the whole of the "same answer 3 times running" failure.
60
+ //
61
+ // The LEVEL travels, not the temperature: the schedule lives once, in
62
+ // `planner.RESAMPLE_TEMPERATURES`, so the two ports cannot drift apart on a
63
+ // number Tier 3 would then average.
64
+ if (resampleLevel && resampleLevel > 0) {
65
+ out[exports.RESAMPLE_LEVEL_ENV] = String(resampleLevel);
66
+ }
67
+ return out;
60
68
  }
61
69
  /**
62
70
  * A log line for a solve invocation, with any credential removed.
@@ -2,8 +2,8 @@
2
2
  * How the driver MOVES. One pluggable object per input device.
3
3
  *
4
4
  * The TypeScript side of `python/src/captchakraken/humanize.py`; the two are one
5
- * design in two languages, and `tests/test_humanizer_parity.py` in
6
- * CaptchaKrakenFinetune pins that the mode names and the pause vocabulary have
5
+ * design in two languages, and `the training repo's humanizer parity tests` in
6
+ * the training repo pins that the mode names and the pause vocabulary have
7
7
  * not drifted.
8
8
  *
9
9
  * Humanisation used to be wired straight into `solver.ts`: every gesture was a
@@ -19,7 +19,7 @@
19
19
  * device farm, a model of their own users) was composing two of them. That
20
20
  * is not hypothetical — camoufox's `humanize` juggler re-humanises every
21
21
  * `mouse.move()` it is handed, and running both measured 82.1s against
22
- * 13.4s on one geetest_v4_slide solve, because each of the 60 trajectory
22
+ * 13.4s on one a GeeTest v4 slider solve, because each of the 60 trajectory
23
23
  * points became its own humanised sub-trajectory.
24
24
  * - a caller on their own infrastructure, against their own fixtures, is
25
25
  * paying for texture nobody is measuring.
package/dist/humanize.js CHANGED
@@ -3,8 +3,8 @@
3
3
  * How the driver MOVES. One pluggable object per input device.
4
4
  *
5
5
  * The TypeScript side of `python/src/captchakraken/humanize.py`; the two are one
6
- * design in two languages, and `tests/test_humanizer_parity.py` in
7
- * CaptchaKrakenFinetune pins that the mode names and the pause vocabulary have
6
+ * design in two languages, and `the training repo's humanizer parity tests` in
7
+ * the training repo pins that the mode names and the pause vocabulary have
8
8
  * not drifted.
9
9
  *
10
10
  * Humanisation used to be wired straight into `solver.ts`: every gesture was a
@@ -20,7 +20,7 @@
20
20
  * device farm, a model of their own users) was composing two of them. That
21
21
  * is not hypothetical — camoufox's `humanize` juggler re-humanises every
22
22
  * `mouse.move()` it is handed, and running both measured 82.1s against
23
- * 13.4s on one geetest_v4_slide solve, because each of the 60 trajectory
23
+ * 13.4s on one a GeeTest v4 slider solve, because each of the 60 trajectory
24
24
  * points became its own humanised sub-trajectory.
25
25
  * - a caller on their own infrastructure, against their own fixtures, is
26
26
  * paying for texture nobody is measuring.
@@ -9,7 +9,7 @@
9
9
  * When installed from npm this file is in `<pkgRoot>/dist` (compiled) or
10
10
  * `<pkgRoot>/src` (dev), and published packages bundle the engine at
11
11
  * `<pkgRoot>/python` (copied in by scripts/copy-python.mjs at build time). In
12
- * the source monorepo it instead lives at the sibling `../python`.
12
+ * the source development checkout it instead lives at the sibling `../python`.
13
13
  */
14
14
  export declare function getBundledCliRoot(): string;
15
15
  /**
@@ -37,7 +37,7 @@ const node_path_1 = __importDefault(require("node:path"));
37
37
  * When installed from npm this file is in `<pkgRoot>/dist` (compiled) or
38
38
  * `<pkgRoot>/src` (dev), and published packages bundle the engine at
39
39
  * `<pkgRoot>/python` (copied in by scripts/copy-python.mjs at build time). In
40
- * the source monorepo it instead lives at the sibling `../python`.
40
+ * the source development checkout it instead lives at the sibling `../python`.
41
41
  */
42
42
  function getBundledCliRoot() {
43
43
  const bundled = node_path_1.default.resolve(__dirname, '..', 'python');
package/dist/solver.d.ts CHANGED
@@ -10,10 +10,71 @@ import { CaptchaKrakenConfig, SolveResult } from './types';
10
10
  * can be pinned by a test — see no-progress.test.ts. The Python port keeps the
11
11
  * same pair on `PageSolverConfig`.
12
12
  */
13
+ /**
14
+ * Does this answer need the widget's box to be performed?
15
+ *
16
+ * Only a coordinate-bearing action does. `done` means "nothing left to click"
17
+ * and is the answer a vendor that closes on success will be showing no widget
18
+ * for, so requiring a box for it fails the solve at the moment it succeeded.
19
+ *
20
+ * Written as an ALLOW-LIST of what needs nothing rather than a list of what
21
+ * does: a new coordinate action added later must default to needing the box,
22
+ * because that is the direction where being wrong throws instead of clicking
23
+ * nowhere.
24
+ */
25
+ export declare function answerNeedsElementBox(actions: ReadonlyArray<{
26
+ action?: string;
27
+ }>): boolean;
28
+ /**
29
+ * Is this the challenge TRANSITIONING under us, rather than a dead puzzle?
30
+ *
31
+ * hCaptcha swaps in the next round while we hold the old iframe, so a
32
+ * screenshot on it fails "not visible" / "Timeout" / "not attached". GeeTest
33
+ * does the mirror image: it CLOSES the panel the moment it accepts an answer,
34
+ * so the widget we are holding loses its layout box between the screenshot and
35
+ * the click. Both are the widget moving on, and the answer to both is to
36
+ * re-detect on the next round rather than to fail the solve.
37
+ *
38
+ * The caller gates this on having ALREADY INTERACTED, which is what keeps it
39
+ * honest: a first-frame failure is a genuine problem and still surfaces.
40
+ *
41
+ * Measured 2026-09-07 on gt4.geetest.com — every live GeeTest attempt ended
42
+ * "Could not get bounding box of captcha element" on a panel the vendor had
43
+ * just accepted, and each was recorded as the model being wrong.
44
+ */
45
+ export declare function isStaleHandleError(message: string): boolean;
13
46
  export declare const SOLVE_DEFAULTS: {
14
47
  readonly maxSolveLoops: 6;
15
48
  readonly overallSolveTimeoutMs: 45000;
49
+ /**
50
+ * How long to hold a click waiting for the screen the model answered about.
51
+ *
52
+ * 9000, not the 6000 it was. A GeeTest svg board dwells p50 1.5s / p75 2.0s /
53
+ * max 2.7s per screen, so a 3-screen cycle runs 4.5s median and 8.1s worst
54
+ * case — a 6s budget could not catch the worst case however well aimed, and
55
+ * gave up one screen short. Named here rather than left as a literal in two
56
+ * places because `video_budget_ms` is derived from it on both ports and the
57
+ * arithmetic has to agree.
58
+ */
59
+ readonly keyframeWaitTimeoutMs: 9000;
16
60
  };
61
+ /**
62
+ * How long a keyframe burst may run before it is called HUNG.
63
+ *
64
+ * A hang detector, not a budget: the solve timeout is what bounds real time.
65
+ * This only has to notice a screenshot that never returns, so it is
66
+ * deliberately slack.
67
+ *
68
+ * Sized off the CEILING, because that is what sizes the loop — the burst plans
69
+ * `videoBurstMaxMs` worth of frames, not `videoBurstDurationMs` worth. Must
70
+ * match `burst_hang_deadline_ms` in the python port, which had this guard
71
+ * first and had it sized off the floor: a 120-frame burst got 17s, i.e. 141ms
72
+ * per frame against a 100ms interval, and a burst that simply ran to its
73
+ * ceiling killed the attempt.
74
+ */
75
+ export declare function burstHangDeadlineMs(cfg: {
76
+ videoBurstMaxMs?: number;
77
+ }): number;
17
78
  export declare class CaptchaKrakenSolver {
18
79
  private config;
19
80
  /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */
@@ -38,9 +99,56 @@ export declare class CaptchaKrakenSolver {
38
99
  private cvWorkerBuf;
39
100
  private solutionCache;
40
101
  private repeatedAnswerSeen;
102
+ /**
103
+ * The ONE recording and the ONE answer for the animated challenge on screen.
104
+ *
105
+ * A cycling board needs a single inference and no more. The burst shows the
106
+ * model every screen; the answer names the cell AND the screen it is on
107
+ * (`frame` / `await_keyframe`); the gate then holds the click until that
108
+ * screen is back up. Nothing about that changes between rounds — the board
109
+ * keeps cycling through the same pictures with the same target — so asking
110
+ * again buys nothing and costs a burst plus a multi-image inference.
111
+ *
112
+ * It was asking again every round. Loops 3, 4 and 5 each re-recorded and
113
+ * re-inferred, which is what exhausted a 66s budget on a puzzle whose whole
114
+ * task is one click.
115
+ *
116
+ * Dropped when the gate reports it never saw the chosen screen: that means
117
+ * the widget is no longer the board this plan was made for, which is exactly
118
+ * when a fresh recording is warranted.
119
+ */
120
+ private animatedPlan;
41
121
  private keyframeMode;
122
+ /**
123
+ * How many distinct steady screens the current clip sits on; 0 if it is not
124
+ * that kind of clip. THE GATE KEYS ON THIS, NOT ON `keyframeMode`.
125
+ *
126
+ * `even` was being read as "no state recurs, do not wait". It does not mean
127
+ * that — it means the slicer could not PROVE recurrence, which for a board
128
+ * whose loop is longer than a 4s burst it never can. 32 of 60 real clips
129
+ * sliced `even` while sitting on 2-3 steady screens, so the gate was off on
130
+ * every real animated captcha and the driver clicked whichever screen was up.
131
+ */
132
+ private keyframeSteadyScreens;
133
+ /**
134
+ * When the current solve must be over, in `Date.now()` terms. 0 outside one.
135
+ *
136
+ * The budget used to be checked ONLY at the top of each solve loop, so any
137
+ * single round could overrun it without limit — and an animated round is the
138
+ * long one: a 4s burst of 40 element screenshots, a multi-image inference,
139
+ * then a gated click per target. Measured on GeeTest svg, 2026-09-07: rounds
140
+ * that re-recorded ran past a 66s budget to the harness's own 180s ceiling
141
+ * and were reported "hung, not slow", which is a message about the model for
142
+ * a driver that simply never looked at the clock.
143
+ *
144
+ * The Python port has always checked mid-wait (`_check_deadline`), so this
145
+ * was also a cross-port divergence on exactly the puzzles Tier 3 times.
146
+ */
147
+ private solveDeadlineAt;
42
148
  private lastAnswerSig;
43
149
  private noProgressRounds;
150
+ /** How far up planner.RESAMPLE_TEMPERATURES this BOARD has escalated. */
151
+ private resampleLevel;
44
152
  private state;
45
153
  private lastSubmitFrameHash;
46
154
  private solveSessionId;
@@ -117,16 +225,37 @@ export declare class CaptchaKrakenSolver {
117
225
  private getVerifyButton;
118
226
  private hasNonEmptyFieldValue;
119
227
  /**
120
- * Detect reCAPTCHA's "Please select all matching images" error banner
121
- * (and the related "Please try again" / "Please also check the new images"
122
- * variants). These appear in the bframe AFTER clicking Verify with an
123
- * incomplete selection. The tiles do NOT refresh on this error — without
124
- * special handling the LoRA sees the same image, returns "done" (because
125
- * to it everything matching IS selected), we click Verify again, and we
126
- * loop until the session times out. We use this signal to switch the next
127
- * grid call into "missed-tiles" retry mode.
228
+ * WHICH of reCAPTCHA's three bframe banners is showing, if any.
229
+ *
230
+ * They share a corner and a look and they mean different things:
231
+ *
232
+ * `rejected` "Please try again." The answer was wrong; a fresh board
233
+ * follows, so the useful response is to solve that one.
234
+ * `select-more` "Please select all matching images." Under-selected, and
235
+ * the tiles do NOT refresh — so a driver that re-submits
236
+ * the same answer loops until the session times out. This
237
+ * is the one the missed-tiles retry and the abort exist for.
238
+ * `dynamic-more` "Please also check the new images." NOT AN ERROR. It is
239
+ * the dynamic 3x3's normal flow: cleared tiles fade out,
240
+ * replacements fade in, and the widget says so — on
241
+ * essentially every round of that variant.
242
+ *
243
+ * This used to return a boolean over all three, which made the third one
244
+ * indistinguishable from the first two and armed the one-retry abort with the
245
+ * sentence that means "you are doing fine". Every dynamic board therefore
246
+ * died at round two while the vendor was still dealing. Measured 2026-09-06:
247
+ * three of three `recaptcha_3x3_fade` attempts stopped at exactly boards=2,
248
+ * against `recaptcha_4x4` passing at 2, 3 and 5 boards on the same run.
249
+ */
250
+ /**
251
+ * May a repeat of this banner end the solve?
252
+ *
253
+ * Only for the banners where repeating means STUCK. `dynamic-more` repeating
254
+ * means the board is still being cleared, which is progress, and treating it
255
+ * as fatal is the bug this pair of methods was split to fix.
128
256
  */
129
- private hasRecaptchaUnderselectError;
257
+ private bannerIsFatalAfterRetry;
258
+ private recaptchaBannerKind;
130
259
  private isRecaptchaAnchorChecked;
131
260
  private isHcaptchaAnchorChecked;
132
261
  /**
@@ -399,6 +528,28 @@ export declare class CaptchaKrakenSolver {
399
528
  * keyframes the wait gate re-reads on every poll live inside it.
400
529
  */
401
530
  private recordKeyframeBurst;
531
+ /**
532
+ * Start recording NOW, and let the caller decide later what it was for.
533
+ *
534
+ * This is what makes the speculative path possible. The burst is the only
535
+ * thing that can tell a cycling board from a still one, and it is also the
536
+ * recording that answers it — so it is started at the same moment the still
537
+ * screenshot goes to the model, and the two run together.
538
+ *
539
+ * - the board never moves -> `moved()` stays false, the still answer is
540
+ * used, and the frames are thrown away. The recording cost nothing but
541
+ * CPU, because it happened inside an inference we were already waiting
542
+ * for.
543
+ * - the board moves -> the still answer is DISCARDED unread, the
544
+ * burst runs on to the end of the cycle, and the multi-image answer is
545
+ * the one acted on. Two inference CALLS, but only about one inference of
546
+ * wall-clock, because the first ran during the recording.
547
+ *
548
+ * The old shape paid for that knowledge in whole ROUNDS: answer a still, act
549
+ * on it, notice nothing changed, answer another still, and only then record.
550
+ * ~15s of a 40s solve to learn something the first four frames already knew.
551
+ */
552
+ private startKeyframeBurst;
402
553
  /**
403
554
  * Slice a recorded burst into keyframes and solve them in ONE model request.
404
555
  *
@@ -434,8 +585,8 @@ export declare class CaptchaKrakenSolver {
434
585
  * one-way fade, a sprite crossing — so there is no state to come back to and
435
586
  * this can only run out its full 6s, PER CLICK, before clicking the
436
587
  * coordinates it already had. It is also the normal case, not a corner: all
437
- * 116 real clips under cleanSamples/test/raw are `even` and `cycle` has never
438
- * fired on real footage. Measured on hcaptcha_rotating_obj_video: 6.0s of a
588
+ * 116 real clips under the held-out sample corpus are `even` and `cycle` has never
589
+ * fired on real footage. Measured on an hCaptcha rotating-object animation: 6.0s of a
439
590
  * 28.8s solve, closest region diff 0.0721 against a 0.05 tolerance, then the
440
591
  * same click, then solved. Kept for `cycle`/`static`, where the state does
441
592
  * come back and waiting is the difference between the sprite and background.
@@ -486,6 +637,20 @@ export declare class CaptchaKrakenSolver {
486
637
  * never animated, so escalating there would swap a path that works for one
487
638
  * that cannot read a grid.
488
639
  */
640
+ /** Forget the recorded answer, and delete the frames it was holding. */
641
+ private discardAnimatedPlan;
642
+ /**
643
+ * Should this round record while it asks?
644
+ *
645
+ * reCAPTCHA is excluded for the same reason it is excluded from the animated
646
+ * path at all: its dynamic 3x3 REPLACES tiles in place, has its own
647
+ * multi-round driver with its own fade gates, and its grids are never
648
+ * animated — so a burst there would film a fade and call it a cycle.
649
+ *
650
+ * Distorted-text rounds are excluded because the answer is a string, not a
651
+ * place, and nothing about a recording helps read one.
652
+ */
653
+ private shouldSpeculate;
489
654
  private shouldRetryAsAnimated;
490
655
  private getSolution;
491
656
  /** One inference: build the CLI invocation, run it, parse what comes back. */
@@ -573,6 +738,32 @@ export declare class CaptchaKrakenSolver {
573
738
  * nothing at all.
574
739
  */
575
740
  private performSmoothMove;
741
+ /**
742
+ * Where in the element this click lands, in element-relative pixels.
743
+ *
744
+ * Split out of `executeClick` because on an animated board the point has to
745
+ * be chosen ONCE and then used three times — to park the pointer, to ask
746
+ * `match-region` about the right neighbourhood, and to press. Choosing it
747
+ * inside the click (it is randomised within the target) meant the gate
748
+ * watched the bbox centre while the press landed somewhere else.
749
+ */
750
+ private clickPointFor;
751
+ /**
752
+ * Click one target on an ANIMATED board, at the moment its screen is up.
753
+ *
754
+ * The order is the point of it. The pointer is parked on the target FIRST,
755
+ * then the gate opens, then the press happens in place — so the only thing
756
+ * between "the right screen is showing" and the click is a mouse-down.
757
+ *
758
+ * It used to wait and then call `executeClick`, which begins with a humanised
759
+ * move: 274ms p10 / 398ms p50 / 647ms max across a 340x384 widget, against a
760
+ * 1500ms median dwell. A quarter to a third of the screen's life spent
761
+ * travelling after we had already confirmed it was the right one — so the
762
+ * gate could do its job and the click still land on the next screen.
763
+ * `move()` short-circuits when it is already at the target, so parking early
764
+ * costs nothing and removes all of it.
765
+ */
766
+ private clickWhenFrameMatches;
576
767
  private executeClick;
577
768
  private executeDrag;
578
769
  /**