Skip to content

2.6.0 — stop waiting for things that cannot happen, and press GeeTest's OK - #9

Merged
JWriter20 merged 28 commits into
mainfrom
dev
Aug 20, 2026
Merged

2.6.0 — stop waiting for things that cannot happen, and press GeeTest's OK#9
JWriter20 merged 28 commits into
mainfrom
dev

Conversation

@JWriter20

@JWriter20 JWriter20 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Supersedes the 2.5.0 description this PR carried — that release is on main's history via the tag; this branch now cuts 2.6.0 on top of it.

A solve-timing release. Nothing here changes what the model answers; it changes how much of a solve is spent waiting for things that cannot happen.

Fixed

  • GeeTest's OK button was never pressed. It ships as <div class="geetest_submit geetest_disable">OK</div> — invisible to both shapes the button finder knew, because a bare div carries no role="button" and "OK" is on none of the four word lists. A GeeTest board does not grade until you press it, so the loop re-read the same unchanged panel and re-answered it identically until the round cap: ordered icon-click scored 0/31 and 0/13 while the model was answering correctly. A driver that discards a right answer is indistinguishable from a model that cannot solve the puzzle, which is how this hid. Matched by class, not by the word — geetest_submit_tips sits beside it, also reads "OK", and does nothing when pressed.
  • A readiness gate with nothing to check no longer reads as "not ready" — 24.0s of a 45.2s solve, three times over, spent asking about elements that were not on the page.
  • An even clip no longer waits 6s per click for a state the slicer already reported never recurs. All 116 real clips are even; cycle has never fired on real footage.
  • The Python burst recorded CSS-animated widgets frozen. animations="disabled" freezes infinite animations, so GeeTest's svg board came back as 40 copies of one picture and honestly sliced to mode=static. The TS port has passed allow since it hit this first.
  • A widget that never renders reports "no captcha" again, not "still detected after N loops". The render-wait cap was a flat 6 and a render wait consumes an attempt, so at maxSolveLoops 6 the NoCaptchaFoundError branch became unreachable — turning the benign answer for a reCAPTCHA v3 / invisible page into a hard error for anyone catching it.

Changed

  • 45s over 6 rounds, down from 120s over 10. A round costs ~4-7s, so six is what fits; ten never did and only ever expressed itself as a timeout. The cap is a backstop now, not how a hopeless solve ends.
  • Outcome window 1000ms polled at 75ms, down from 2500ms at 200ms. Sized on how late a real success arrives (p50 360ms, max 528ms over 34 successful rounds across 20 puzzle types), not on how long a wrong answer takes to produce nothing — 25.5s of one 66.1s solve was this window.
  • hCaptcha's image wait bounded at 3s, not 8. It is best-effort; the screenshot happens either way.

Added

  • A solve that repeats itself is abandoned (maxNoProgressRounds / max_no_progress_rounds, default 2). At temperature 0 an identical answer means an identical picture, and every answer this driver produces is executed — so the previous one already ran and moved nothing. Measured on recaptcha_grid_4x4: nine identical click sets, each clicked, each rejected, ending at 66.1s. Now ends at round 4. 2 rather than 1 because the first repeat escalates to a recording, which is the one recovery still worth trying.
  • A per-solve phase budget under CAPTCHA_TIMINGS=1 (Python). "The solve took 77s" is not actionable; "the settle monitor spent 31s of it" is.

Both ports, per CLAUDE.md 1c — the timing half had landed in Python only, and Tier 3 gates on (puzzle type, port) pairs.

Also

The MCP model listing derived "downloadable" from the hosted flag, so it was wrong in both directions at once: it announced the unreleased model as "what the hosted API runs" and reported the model actually taking requests as "reserved, not uploaded yet". Both flags are now read for what they say. Abyss is named as the Qwen3.8-27B it will be, against the Qwen3.5-9B everything public is.

Versions

captchakraken 2.5.0 → 2.6.0 (npm + PyPI), captchakraken-mcp 0.1.0 → 0.1.1. Merging publishes all three; the MCP needed its own bump or the listing fix would sit on main and never reach anyone.

Verification

Python client 404 passed, JS client 85 passed, both typecheck clean, prompt-parity gate green. Tier 2/3 have not run locally — that is what this PR is for.

🤖 Generated with Claude Code

JWriter20 and others added 25 commits August 2, 2026 23:49
Two answer families the model was already trained to produce but the
driver silently discarded. `ActionPlanner._normalize_pixel` dropped both:
a `{"action": "type", "text": ...}` answer carries no coordinate for any
branch to key off, and the drag branch required both endpoints, so the
sourceless drag the slider prompt explicitly asks for parsed to nothing.

Text: the driver decides from the DOM, not the picture — a visible text
box selects the distorted-text prompt and skips grid detection, since
BotDetect's boxed glyphs are the very lattice find_grid looks for. Typed
character by character with jittered gaps after a real pointer move and
click; fill() would set the value with no keystrokes, and these vendors
score cadence.

Sliders: closed-loop rather than calculated. The model gives the gap
centre but not the handle-travel ratio, which vendors vary deliberately.
The driver nudges twice by known amounts and measures union(before,
after) to solve for piece_width and ratio, then steers the remainder.
The button is never released until the piece is home — on these puzzles
releasing is the submit.

New surface: TypeAction (JS), nullable DragAction.source_bounding_box
(null = slider), TEXT_INPUT/SLIDER_HANDLE/DRAGGABLE_PIECE selectors,
slide_* knobs on PageSolverConfig, tool_calls/track_piece.py, and CLI
track-piece (with a serve mode, since it runs several times per drag).
prompts.py gains the generation-2 `text` family.

Requires a generation-2 model; generation 1 is never asked for either
answer and now reports UnsupportedCaptchaError naming that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cell mid-replacement (big check over the photo, fading to white, or a new
photo fading in) is on its way to showing something else, so it must not be
clicked however well it matches. An already-selected cell still counts.

Generation 2 is unpublished — no models.json entry names it — so its text is
still free to move. It freezes the moment a model registers against it.

test_move_indicator: follow the recaptcha_grid_3x3 overlays into their
standard/ variant subdirectory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`solve_captcha` forwarded **kwargs to the CaptchaSolver CONSTRUCTOR rather
than to solve(), so `text_mode=True` raised TypeError on the shipped entry
point. Tier 2 could not ask the text families for an answer at all — no DOM,
so nothing else set the flag — and the three text types sat unmeasured while
looking like ordinary low scorers.

Forward the solve-time arguments explicitly instead. A unit test on a mock
missed this because the mock accepted anything; test_solver_contract now binds
the real signature with inspect.

page_solver: a typed answer needs its own submit. `typed` is tracked
alongside `slid`, and verify-button discovery widens from the frame to the
enclosing scope ONLY when something was typed — widening it for every puzzle
let a solve press the host page's own submit mid-run. `_get_verify_button`'s
xpath goes relative (`.//button`) so it resolves against an ElementHandle.

Also drops hcaptcha_block_cover from the type taxonomies, which removes the
n=1 entry the contract test flagged as a permanent-0% scoring hole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_grid finds any lattice, and _is_real_grid only rejected the flat-band
false positives. GeeTest's iconcrush board cleared every check (mean per-cell
stddev ~52) and was routed to _solve_grid on every real capture, so the model
was asked to 'choose the cell numbers that match the description' on a board
whose answer is tapping two tiles to swap them. It replied [3, 6, 9], that was
re-encoded as clicks on three cell centres and graded against a two-click gold:
0.089, the lowest score in the Tier 2 table, for a question we never asked.

Reject on the BACKGROUND rather than the content: independent photographic
tiles have their own median colours, a sprite board shares one board colour
behind every cell. Real captures measure 0.0-0.8 for iconcrush/gobang against
14.9-93.0 for the five true grid families, so the 6.0 cut sits in an 18x gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight vendors render into the HOST PAGE rather than a vendor iframe (GeeTest,
Yidun, Tencent, Yandex, Lemin, Prosopo, MTCaptcha, BotDetect), so
content_frame() is None for every one of them. The Verify button was looked up
only inside that frame, so on those widgets it was never even SEARCHED FOR: the
solver clicked the right tiles, or dropped the piece in the right hole, and then
re-solved a puzzle it had already answered until the loop cap or the deadline.

Traced on the Tier 3 fixtures against the pinned model:

  prosopo_grid_3x3  loop 1 clicks four tiles, loop 2 the model answers `done`
                    because everything matching is already selected. `done`
                    performs no action, so the caller's "performed no
                    interactions" guard fired and aborted a puzzle whose only
                    remaining step was to press the button.
  lemin_cropped     drags the piece into the gap on loop 1, then nudges it a
                    pixel a round for nine more rounds, because nothing ever
                    tells the widget to grade it.

Three changes, one per link in that chain:

- Look the button up in `scope` when there is no content frame. This was gated
  on `typed` for fear of turning up the submit of the FORM the captcha guards;
  `scope` is the widget container and the xpaths are relative, so that button is
  out of reach by construction. What the gate actually did was make every
  non-typed inline puzzle unsubmittable.
- A PLACED PIECE submits. A drag with a source has no count to reach that could
  auto-submit it and no release being graded, and unlike a click round it is
  never followed by a `done`. A click round is deliberately still not
  auto-submitted: those boards re-round, and submitting a half-made selection
  spends the attempt.
- The press counts as an interaction. Reporting false after submitting re-arms
  the guard one loop later, which is the abort prosopo hit.

The TypeScript port needed all three plus two of its own: its submit was gated
on `frame` being non-null, so no inline widget could ever submit there even
after typing (the Python port got that fix, TS did not), and getVerifyButton's
xpaths were document-rooted, which does not resolve against an element handle.

prosopo_grid_3x3 now solves in 13.6s where it aborted at 14.3s. lemin_cropped
now presses Verify every round and the widget rejects it — its remaining
failure is the answer, not the driver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sliderless branch of executeSlide moved to the target's X while HOLDING THE
PIECE'S OWN Y. On a rail that is right and not even a choice — the handle only
travels horizontally — but Lemin's "cropped" has no rail: the piece is carried
freely onto the photo, and its slot is 250 px up the card. So the piece slid
along the tray and was released there, and every attempt submitted a piece that
had never left it.

Reachable only from a SOURCELESS drag, which lemin_cropped no longer emits, but
every model published before that data change still does and the shipped client
has to keep working with them.

Both ports, same one-line shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The submit policy named four one-shot cases (hCaptcha, reCAPTCHA 4x4, a typed
code, a placed piece) and excluded click rounds, on the reasoning that those
boards fade and re-round so a half-made selection would spend the attempt.

They do not. A board that grades itself mid-selection draws NO submit control —
it verifies on the Nth tile, or on the swap, or on release — so `verify_button`
is None there and nothing is pressed either way. The one family that genuinely
fades and re-rounds is the reCAPTCHA 3x3, which never reaches this code: it
returns to its own driver several hundred lines earlier.

What the exclusion actually bought was an extra model call per puzzle, spent
asking a board we had already answered correctly whether it was `done`, purely
so the press had a round to happen on. Measured on prosopo_grid_3x3: 6.2 s of a
13.8 s solve, on a selection that scored 1.0 on the first call.

    prosopo_grid_3x3   python   12.1-14.4s -> 9.6s / 7.7s
    geetest_v4_nine    python               -> 7.9s / 5.4s
    yidun_iconclick    python   10 loops    -> solved, 11.3s
    yidun_pictureclick python   10 loops    -> solved, 12.1s

The rule is now smaller than the list it replaces: press the widget's own
submit control whenever we put an answer into it, or when we had nothing to do
and want the round to advance. Two exclusions — a completed slide has already
submitted, and a round that only waited has answered nothing. hCaptcha and the
4x4 are ordinary click rounds and fall out of it.

What keeps the press ours to make is not the answer's shape but that discovery
is relative to `scope`, the vendor's own widget container: the host page's form
submit is unreachable by construction. That invariant now has its own test,
replacing the two that pinned the exclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every UnsupportedCaptchaError became "the rendered puzzle is not a supported
grid or checkbox (likely an hCaptcha click/drag puzzle)" — including the ones
that already named both the cause and the fix:

    prompt generation 1 has no distorted-text prompt — model was trained
    before the text family existed. Use a model on generation 2 or later.

The gate reported the guess. It is wrong (the puzzle is a text captcha, not an
hCaptcha click puzzle) and it points at the solver when the answer is the
model's vintage. Both ports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
js/src/solver.ts has imported './trajectory.js' since 8b09790, but the file
was never added — so `dev` has not compiled for three commits:

  src/solver.ts(14,37): error TS2307: Cannot find module './trajectory.js'

Add it, and drop the cursory-ts dependency the import replaced.

Both drivers also stop grinding against a frame that is being torn down:

  - the response token is read FIRST and unconditionally. It is a hidden
    field on the page, not in the widget, so gating it on the anchor being
    visible meant the one signal that was already true went unread exactly
    when it mattered — hCaptcha keeps its overlay up for a couple of seconds
    after the winning submit. Turnstile's field is read too; detect_captcha
    already used it to decide a widget was UNSOLVED.
  - "prompt painted" no longer counts as a next round. The frame must have
    changed since the submit, which is already snapshotted at submit time.
  - the ~21s of readiness waits are followed by one more solved-check before
    spending an inference, the most expensive step in the loop.

Examples take a URL argument and default humanize OFF; composing camoufox's
humanize with the solver's own 60-point trajectory measured 82.1s against
13.4s on one geetest_v4_slide solve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ehind

Retract the published accuracy figures. They were measured 2026-07-27 under an
eval split that let hand-labelled captures reach training, so they scored
memorisation alongside skill — by an amount that was largest exactly where the
headline number was highest. The 4x4 read 97.0%; on the split that holds every
real capture out it reads 0.2021. Both README and docs/performance.md now say
what happened and what will be measured instead, rather than restating figures
we no longer stand behind. The overlay ablation keeps its finding — a 4x4
without cell numbers solves ZERO boards — but stops quoting the retracted
absolutes as its baseline.

List the vendors the driver has been solving without saying so: GeeTest v3/v4,
Yidun, Lemin, Prosopo, Tencent, and the distorted-text families (BotDetect,
MTCaptcha, Yandex). Tier 3 drives all of them to completion in both ports;
geetest_v4_gobang and geetest_v4_iconcrush do not, and are named as weak.

The logo replaces the emoji, as a <picture> so it survives dark mode — the
artwork is near-black and would have vanished into a dark background. Both
variants are generated from the same source by overriding --ck-ink/--ck-field.

Also:
  - the reproduce-it-yourself block pointed at tests/live-solve in a repo the
    reader cannot clone. It now uses the examples in THIS repo, with the cd and
    the two install lines a first-timer needs.
  - drop planner_types.py: superseded by action_types.py (ids vs bounding
    boxes), imported by nothing, exported by nothing.
  - `v1-old-architecture` has not existed on the remote for some time; two
    places sent readers to it.
  - .env.example still called the hosted API "coming soon".
  - the roadmap listed drag-and-drop as planned two rows under shipping it.
  - keyframes.py described a fixed 4 s burst; Source.burst_duration_ms has
    made clip length per-vendor. Patched in both parity-locked copies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapters train with MIN_PIXELS=200704 (448², exported by the finetune
repo's scripts/train_unified.sh), so anything smaller is enlarged before the
ViT sees it. Inference did none of that: vLLM runs with no
--mm-processor-kwargs and this client re-encoded the file byte-for-byte, so a
small captcha arrived at a geometry the model was never tuned on.

It fails as plausible-but-wrong coordinates, never as an error. On real
geetest_v3_slide captures (277x285 = 78,945 px) predictions landed 80-105 px
from the hand label at native size and 1-4 px away once upscaled. The affected
types scored 0.000-0.119 while types whose captures clear the floor scored
0.704 — the split follows image size, not puzzle difficulty.

A floor, not a resize: images already above it are passed through unchanged,
so the types that were never broken pay nothing. Unconditional rather than
keyed to a model generation, because the deployed v1.1 adapter improves under
it too (mean error ~40 px native vs ~4 px upscaled).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A gutter is traced once per seed row, so `_merge_lines` collapses ~12 duplicates
of it. `_pick` kept `max(group, key=support)` — the LONGEST member, which is
systematically the trace seeded at the gutter's EDGE: long, but running along
tile content rather than the separator. It then re-centred `midline_pos` to the
support-weighted mean and kept that edge trace's `color_lab` and `color_std`,
throwing away the on-centre traces entirely.

So every colour gate downstream judged the grid by a line that was not on it.
Measured on hcaptcha_images_ice_cream4: the kept trace sat 11 px off the gutter
on a pale watermark that exists at some x and not others, carrying color_std
2.80, while the on-centre traces of the same gutter measured 0.55-0.80. Mean
gutter std came out 2.69 against CLEAN_GUTTER_STD 2.3, which withheld the
pale-tile relaxation in `_cells_have_content` and rejected a correct 3x3 that
was the only candidate.

Ordered by cleanliness rather than distance to centre — a member a hair nearer
the centre can still be the dirtier read, and preferring it swapped a 0.00 trace
for a 3.43 one on recaptcha_1774954808237_rhitt and lost a grid that detected
fine. Restricted to members of comparable support so a three-sample fragment
cannot win on a trivially perfect std.

Measured over the full 50,420-image corpus, baseline vs this:
  real grid        1,396   98.71% -> 99.07%   (+5, none lost)
  synthetic grid   6,000   95.18% -> 96.45%   (+76)
  real non-grid    1,318    5.61% ->  5.77%   (+2, both GeeTest board games)
  synthetic non-grid 41,706 3.32% ->  3.32%   (flat)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…v1.1

SOLVE CAPTCHAS AS THEY APPEAR
`solver.watch(page)` (TS) and `PageSolver.watch(page)` (Python) install a
watcher that probes the page and solves any challenge that becomes visible, so
a script no longer has to know where a captcha might interrupt it.

It injects NOTHING into the page. The obvious build — a MutationObserver
signalling out through an exposed binding — reacts faster and is the one design
that cannot be stealthy everywhere, because a binding is a function on `window`
and an observer is script a vendor can enumerate. Instead it drives the existing
detectCaptcha() from the driver side on a timer: no new detection surface on any
launcher, and under camoufox those DOM reads land in the sandboxed Juggler world
for free, since that is camoufox's default for all Playwright evaluation
(`main_world_eval` / an "mw:" prefix are the opt-OUT). The cost is reaction time
bounded by interval_ms rather than by the mutation.

Python blocks (`run()`) where TypeScript returns a handle, and adds
`poll_once()` for callers with their own loop. That asymmetry is Playwright's:
a sync handle is bound to the greenlet that created it, so a worker thread
cannot drive the page and a background watcher is not available to it.

THE PUPPETEER ADAPTER WAS NEVER ACTUALLY VERIFIED
Its header claimed it was "verified against Puppeteer 24.x" while nothing in the
package touched it — no test, and Tier 3 drives camoufox on both ports. It now
has unit tests for every API delta it bridges, plus browser-compat.test.ts,
which launches real Puppeteer and real Playwright and drives every member of the
structural page surface through them. Both were run: 26/26 adapter methods
against Puppeteer 24.43.1, and the full surface against Playwright 1.62.1 with
no adapter at all. Skipped when those libraries are absent — the package still
ships with zero browser dependencies.

That verification found a real gap: fromPuppeteer did not forward `isClosed`,
which the watcher reads to end its loop. A Puppeteer-driven watcher would have
polled a closed page forever.

`npm test` no longer names each test file. It compiles tsconfig.test.json, so a
new test cannot be silently left out of the run — which had already happened.

MODEL: v1.2 IS PUBLIC, AND IS NOW THE DEFAULT
CaptchaKraken-Lora-v1.2 is published under Source-Available License v1.1. It is
the first generation-2 model, so `latest` and pinned_model.json move to it
together — the registry entry that names the weights is the same one that
declares prompt_version 2, which is what keeps the pair from drifting. The
serving alias moves to `captcha-v12`, which the fleet already serves alongside
`captcha`. prompt_sha256 is re-pinned to the generation-2 templates.

LICENSE v1.1
Adds 3(d): shipping the solver as a built-in captcha feature of a stealth or
antidetect browser distributed to third parties needs a commercial license.
Using it with one stays explicitly permitted (2(c)). Copyright holder is
CaptchaKraken LLC across all four LICENSE copies, and the remaining JobHarvest
references are gone — CaptchaKraken and JobHarvest are separate entities.

Also in this release (in flight before it): per-model pixel_budget, so an
adapter is served at the MIN_PIXELS/MAX_PIXELS band it trained under; and the
MCP server tolerates a control plane older than migration 0008.
…hat is not a constant

Five examples, each run end to end against the real captcha-v12 adapter before
being committed — against tests/fixtures reCAPTCHA 3x3, and additionally
hCaptcha 3x3 property and geetest_v4_slide through the Puppeteer path:

  withPlaywright.ts    vanilla Playwright, no adapter        2 added lines
  withPuppeteer.ts     Puppeteer via fromPuppeteer(page)     2 added lines
  watchPlaywright.ts   the auto-solver
  with_playwright.py   vanilla Playwright, no adapter        2 added lines
  watch_playwright.py  the auto-solver

Two things the first draft got wrong, which is the reason for running them:

  * top-level await. This package is CommonJS and tsx transpiles these to CJS,
    where top-level await is a syntax error. They use `async function main()`,
    which runs unchanged under either module system.
  * the bundled Python engine. `npm run build` recopies python/ over
    js/python/, wiping the venv that scripts/setup-python.js creates in
    postinstall — so the CLI fell back to a bare `python3` with no pydantic. A
    user installing from npm never sees this; anyone running examples from a
    built checkout does.

There is no Puppeteer path in Python, and the docs now say so with the reason
rather than leaving it to be discovered: pyppeteer last released in February
2024 and pins urllib3 <2.0.0 against this package's own requests dependency, and
it is async-only against a synchronous driver. Two independent blockers.
Playwright is the Python answer; patchright and camoufox duck-type identically.

PIXEL BUDGET
test_an_image_above_the_floor_is_left_alone asserted against the module constant
P.MIN_PIXELS while the planner resolves a budget PER MODEL. Those agreed only
while the pinned model declared no budget of its own. They no longer do: v1.2 is
pinned and declares a flat 720 squared band, so "above the floor" stopped
meaning "untouched" for it — by design, since a flat band exists to give the
adapter exactly one geometry.

Split into the three things that were being conflated: the open-band rule
(pass through byte-for-byte, driven with an explicit budget), the flat-band rule
(normalise everything), and the planner's own resolution, which reads the pinned
model's band and so legitimately moves when the pin does.

The flat-band ceiling is asserted as `(w-1)*(h-1) <= maximum`, not `w*h`. The
floor branch scales with ceil() on both dimensions on purpose, so on a flat band
it necessarily overshoots by up to one row and column — 400x580 is sent as
598x867 = 518,466 against a stated 518,400. That is 0.013% over and inside the
same ViT patch grid; asserting equality would be asserting that ceil() does not
round up.
They import 'captchakraken' the way real code does, which resolves from npm for
a user and from nothing inside this repo. Both footguns I hit running them are
written down: the self-link, and that npm run build wipes the bundled python
venv so a solve dies on a missing pydantic.
…de installer

A `Page` outlives navigation and so does the watcher holding it: install once
and it keeps probing across every goto, quiet when there is nothing to solve and
re-armed after each solve. Nothing pinned that, and it is the claim the whole
per-page API rests on. Now tested in both ports: two clean navigations solve
nothing, a challenge on the third is solved, and a fourth proves it is still
armed rather than having fired once.

That measurement is also why the browser-wide `attach()` this started as is not
here. It was built, and it worked live on a Playwright Browser, a Playwright
BrowserContext and a Puppeteer Browser — then the navigation test showed the
case motivating it was already covered. A challenge on request 40 of a scrape
arrives on the SAME page object the run started with, so `watch(page)` handles
it in one line. What a browser-wide installer uniquely adds, once navigation is
covered, is pages the SITE opens: popups and target=_blank. Captchas there are
rare, and the price was a background sweep per browser, a lifecycle callers must
remember to stop, and an unattended process that solves and bills whatever it
finds on any tab. That last one lands on us as refunds rather than on the user
as a bug. Worth adding later on evidence, at the context level where Playwright
has a real `page` event; not worth carrying on speculation.

A shared/static solver was never an option and is worth recording as the reason:
CaptchaKrakenSolver holds ~15 mutable per-solve fields and takes no lock.
`solveSessionId` is minted at the top of solve() and nulled in its finally,
which is what groups a multi-round 3x3 into ONE billable attempt; two pages
through one instance either merge sessions or clear each other's mid-flight.
The same finally calls teardownCvWorker(), killing the shared CV child process
under the other page's solve. And lastMousePosition is the cursor continuity of
one page — sharing it teleports the cursor between tabs, which is the exact
signal the humanised trajectory exists to avoid.

The navigation test waits on the solve COUNT rather than sleeping a fixed span.
`node --test` runs files in parallel, so several Chromium launches compete for
CPU: the first version passed in isolation and failed in the suite.
GeeTest's svg variant went 0 for 16 live. Two bugs stacked, and each hid the
other.

THE SETTLE PROBE IS SHORTER THAN THE DWELL
waitForElementSettled calls a challenge static after settleFrames (2) still
polls at settlePollMs (220ms) — about 440ms of stillness. That board advances
through screens of fresh glyphs and HOLDS each one for p50 1.5s, p75 2.0s, max
2.7s (measured over 81 clips; see sources.py). It clears a 440ms bar trivially,
so it was read as a picture and answered from whichever screen we caught, and
the click landed after that screen was gone.

THE CACHE THEN MADE IT PERMANENT
The answer cache keys on the screenshot's bytes and reasoned "identical pixels
mean the page has not changed, so reuse the answer". On a cycling board the
pixels come back around, so later rounds hashed the same and REPLAYED the
answer that had just failed, without asking the model: 81 solve loops, 12 model
calls, 69 cache hits, 0 solves.

The invariant it was missing is that every answer getSolution returns is
executed — there is no speculative call. So an identical screenshot cannot mean
"nothing changed, reuse it". It means the answer already ran and moved nothing,
which is the one case where replaying it is certainly wrong. The saving is
kept (a hit still costs no inference) and the hit is now treated as EVIDENCE
that the still reading was wrong; the round after it records a burst instead.
reCAPTCHA is excluded — its dynamic 3x3 replaces tiles in place, has its own
fade-gated driver, and its grids are never animated.

THE BURST WAS FREEZING WHAT IT CAME TO RECORD
With the escalation in, the burst still sliced to `1 keyframe (mode=static)`.
recordKeyframeBurst screenshotted with animations: 'disabled', which is correct
everywhere else in this file — it is how you get a stable still — and exactly
wrong here, because it FREEZES infinite animations. Forty frames of the same
picture. hCaptcha's animated challenges hid it by animating in canvas, which
that flag does not touch; the svg board animates in CSS, which it does.

Live after both fixes: 0/16 -> 5/6, bursts slicing to 2-3 real keyframes.

The Python driver has no answer cache, so only the JS port needed the first
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reCAPTCHA solve measured 48.9s. The actual work took eight. Three bugs, all
of them the same shape — waiting on a challenge the vendor had already torn
down — and two of them were fixed once already, elsewhere in this file.

1. SIX ELEMENT SCREENSHOTS WITH NO TIMEOUT
   Playwright defaults to 30s and waits for the element to be visible and
   stable first. On a closing iframe that runs to the full default.
   waitForElementSettled already carries this fix and a comment saying it "made
   a multi-round solve take ~115s"; waitForGridCellsLoaded and the whole grid
   path were written in the same shape without it. A poll loop hides it, too:
   its `while (Date.now() - start < timeout)` is consulted only BETWEEN
   iterations, so one hung screenshot sails past the 8s cap it looks protected
   by. screenshot-timeouts.test.ts now reads the source and refuses a bare one.

2. THE GRID DRIVER NEVER RECORDED THE FRAME IT SUBMITTED
   The one-shot path snapshots lastSubmitFrameHash on submit; the reCAPTCHA
   grid driver clicked Verify without it. That hash is what tells the next
   attempt "the board on screen is the one you already answered".

3. AND THE reCAPTCHA BRANCH OF isChallengeFreshlyRendered NEVER CHECKED IT
   The hCaptcha branch does, with a comment describing this exact failure:
   firing on the closing frame "committing the solver to ~21s of readiness
   waits and a full inference against a dying frame". reCAPTCHA leaves its
   answered board up while it verifies and keeps the instructions visible, so
   "instructions are showing" reported a fresh round that was never coming.

Together: the post-submit solved-poll broke on its first tick, the solver
re-detected the dying bframe, and every screenshot against it ran to a
timeout — 40 seconds, on every multi-round reCAPTCHA, paid by every customer.

Measured live on the demo page, same harness, same model:
  before   48.9s median, 24/35 solved
  after     7.8s median (3x3) / 10.8s (4x4), 8/8 solved

The solver also now asks the vendor whether the captcha is already solved
before starting another detect pass, which is the cheapest question available
and was not being asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reCAPTCHA/hCaptcha misreads that both cost a solve without erroring.

reCAPTCHA answers a click one of exactly two ways: a small blue chip in the
tile's top-left (the photo was KEPT, the selection is the answer) or the photo
dissolving under a centred check (the tile is being SWAPPED). We only looked for
the swap, so a chipped board burned rounds waiting for a refresh that was never
coming. `_watch_clicked_tiles` now reports both and submits on the chip. The chip
is tested FIRST: it zooms the photo, which reads as `changing` on the same frame,
so testing the swap first makes every chipped board look like a swapping one.

hCaptcha's selected-state badge was matched by counting teal pixels and white
pixels anywhere in the same corner — satisfied by blue sky with a white pole in
it, 74 phantom selections over 3051 corners of boards with nothing selected. A
phantom drops the tile from the model's answer and it is never clicked. The
counts stay as a cheap gate; the verdict is now that the white BELONGS TO the
teal mark, within 2px of the largest blob's convex hull.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`latest` moves to CaptchaKraken-Lora-v1.2 (generation-2 prompts), and setup.sh
and .env.example follow it. Those two defaults MUST match the registry: the env
file setup.sh writes overrides the client's own defaults, so an adapter that is
not registered resolves to generation-1 prompts against generation-2 weights,
silently, on every puzzle. test_setup_sh_model_ids.py pins the match.

v1.2's pixel_budget becomes a flat 720^2 (floor == ceiling), which is a MEASURED
serving value rather than the training floor. Sweeping 448/576/640/704/720/736/
768/896/1024^2 over the full 1458-sample Tier 2 put the plateau at 704-736^2:
overall 0.6115 -> 0.6412, reCAPTCHA 3x3 exact 0.648 -> 0.715, against run-to-run
noise of +/-0.001. 640^2 saturates the ViT's 48x48 position grid exactly and
scores WORSE, so this is not a case of staying inside the grid.

Adds AGENTS.md and docs/hosted-api.md, and stops telling a user with no GPU to
watch the repo for a hosted API that now exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ms, and the v1.2 client defaults

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs described one lineup of models named for ocean depth, which read as a
capability ladder and hid the thing a downloader most needs to know: v1.1 was
trained on reCAPTCHA and hCaptcha ALONE. It does not score badly on a GeeTest
slider or a typed-text captcha — it was never shown one and will not attempt it.

So the models are now presented as two releases, each carrying the same weights
in three shapes (LoRA + Twilight FP8 + Sunlight AWQ):

  v1.2  44 puzzle types across 10 vendors, animated challenges, typed text.
        Prompt generation 2, needs captchakraken >= 2.5.0.
  v1.1  reCAPTCHA and hCaptcha only. Generation 1. Still published.

The hosted API is described as answering with Twilight v1.2 — the same LoRA on
the same base, merged — rather than "the production adapter", which named
nothing a reader could go and look at.

Registers both v1.2 merges in models.json, which is what publishing them means:
a merge that resolved to a different prompt generation or pixel budget than the
adapter it was made from would be the 2026-07-18 regression with extra steps.
Both carry prompt_version 2 and the measured flat 720^2 band. Parity gate passes
at 7 registered models.

Abyss is unchanged: in training, never published, hosted-only when it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1.2 cards inherited v1.1's figures. Built and weighed: Twilight 13 GB (not
~14), Sunlight 11 GB (not ~9.1). Sunlight's minimum moves 11 -> ~14 GB with it.

Sunlight being LARGER than its v1.1 counterpart at the same 4-bit scheme is not
a mistake and the card says so: only the language-model linears are quantized,
and this generation's vision tower stays bf16 deliberately. Quantizing the half
of the model that reads the picture is not a size win worth making.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A solve-timing pass over both ports. Nothing here changes what the model
answers; it changes how much of a solve is spent waiting.

GeeTest ships its submit as `<div class="geetest_submit geetest_disable">OK`,
invisible to both shapes the button finder knew — a bare div carries no
role="button" and "OK" is on none of the four word lists. A GeeTest board does
not grade until you press it, so the loop re-read the same unchanged panel and
re-answered it identically until the round cap: ordered icon-click scored 0/31
and 0/13 while the model was answering correctly. Matched by class, not by the
word, because `geetest_submit_tips` sits beside it and also reads "OK".

The rest is dead time. A readiness gate with nothing to check returned "not
ready" and polled out its full window (24.0s of a 45.2s solve). An `even` clip
waited 6s per click for a state the slicer had already reported never recurs —
and all 116 real clips are `even`. The Python burst recorded with animations
disabled, which freezes infinite ones, so every CSS-animated vendor was filmed
frozen. The verdict window spent 2500ms on every wrong answer to catch a
success that measured p50 360ms / max 528ms.

And a solve that repeats itself now stops instead of letting the clock do it:
at temperature 0 an identical answer means an identical picture, and the
previous one already ran and moved nothing. Measured on recaptcha_grid_4x4,
nine identical click sets ending at 66.1s; it now ends at round 4.

Both ports, per CLAUDE.md 1c — the timing half had landed in Python only, and
Tier 3 gates on (puzzle type, port) pairs.

Two bugs found finishing it:

- Dropping max_solve_loops 10 -> 6 made NoCaptchaFoundError unreachable. The
  render-wait cap was a flat 6 and a render wait consumes an attempt, so the
  loop ran out first and a reCAPTCHA v3 / invisible page reported "still
  detected after 6 solve loops" instead. Tied to the loop count in both ports.
- Three test helpers built a PageSolver with __new__ and hand-listed the
  attributes __init__ sets, so every attribute added since broke them with an
  AttributeError raised nowhere near the behaviour under test. They call the
  real __init__ now.

The lineup is stated where it was wrong, too: the MCP listing derived
"downloadable" from the `hosted` flag, so it announced the unreleased model as
what the hosted API runs and reported the model actually taking requests as
"reserved, not uploaded yet". Both flags are read for what they say, and Abyss
is named as the Qwen3.8-27B it will be, against the 9B everything public is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
publish.yml runs on a push to main and publishes each package at the version in
its own manifest, skipping one already on the registry — so a merge that does
not move these numbers lands the code and ships nothing.

The client is a minor: three new config options (maxNoProgressRounds,
postSolveOutcomePollMs, hcaptchaImagesTimeoutMs) and changed defaults for two
that already existed (overallSolveTimeoutMs 120s -> 45s, maxSolveLoops 10 -> 6).
Additive API, changed behaviour.

The MCP is on its own version line and is deliberately not locked to the
client's number, but its model listing changed this release, so it gets 0.1.1 —
without it the fix sits on main and never reaches anyone.

__version__ was still "2.3.0", three releases behind pyproject. Nothing reads
it, which is exactly why it drifted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JWriter20 JWriter20 changed the title 2.5.0 — auto-solve watcher, a verified Puppeteer adapter, and License v1.1 2.6.0 — stop waiting for things that cannot happen, and press GeeTest's OK Aug 19, 2026
JWriter20 and others added 2 commits August 20, 2026 09:52
`solver.ts` defaulted the model to the literal `'captcha'` in both
getSolution and getAnimatedSolution. That is the served alias of
CaptchaKraken_v1.1 — a PROMPT GENERATION 1 model — while the Python
client resolves models.json's `latest` and has been getting `captcha-v12`
(generation 2) since v1.2 was registered on 2026-08-12.

The name is not just routing. This port answers by shelling out to the
Python CLI and passing it as `--model`, and `prompts.resolve()` maps that
name through models.json to a prompt generation. So every JS solve sent
generation-1 prompts to generation-2 weights: silent for every family
generation 1 still has a prompt for, and a hard error for the two it does
not. Tier 3 on 2026-08-20 lost 22 of this port's 44 puzzle types to it —
19 to `prompt generation 1 has no animated-puzzle prompt` and 3
(botdetect_text, mtcaptcha_text, yandex_text) to the distorted-text
equivalent surfacing as UNSUPPORTED_CAPTCHA — against a Python port that
scored 37/44 on the same commit. That asymmetry is what gave it away.

model-name.ts mirrors config.py's precedence exactly (CAPTCHA_LORA_NAME,
then `latest`, then pinned_model.json) so the two ports cannot drift
again, and is exported so a reporter can ask rather than re-derive.

Driving the six worst-affected types locally against the primary GPU
after this change: js and python now agree pair for pair, 10/12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This repo ships the DRIVER. A pull request here cannot change the model, so
gating it on a Tier 2 solve-rate measurement gated the one thing the PR
could not move — the same reasoning that took Tier 2 off the private repo's
own PR path.

What a PR here CAN break is driving: a selector that stopped matching, a
click landing off-widget, or one port asking for a different adapter than
the other. That last one is not hypothetical — it shipped, and on
2026-08-20 it cost the JS port 22 of its 44 puzzle types while every check
on this repo stayed green. Tier 3 is the only gate that drives both shipped
ports end to end through a real browser.

The status context moves with it: `tier2/solver-gate` -> `tier3/driver-gate`,
so a branch protection rule pinned to the old name cannot be silently
satisfied by a different measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JWriter20 JWriter20 closed this Aug 20, 2026
@JWriter20 JWriter20 reopened this Aug 20, 2026
@JWriter20 JWriter20 closed this Aug 20, 2026
@JWriter20 JWriter20 reopened this Aug 20, 2026
`gh workflow run` resolves the target repo's default branch over GraphQL
before dispatching, purely to choose a ref. That lookup reads repository
METADATA, so the convenience costs a permission the dispatch itself does not
need — and PRIVATE_DISPATCH_TOKEN, scoped to `Actions: write` on the private
repo, failed on precisely that:

  unable to determine default branch for JWriter20/CaptchaKrakenFinetune:
  GraphQL: Resource not accessible by personal access token
    (repository.defaultBranchRef)

Calling the dispatch endpoint directly needs only Actions: write. Widening
the token to satisfy a lookup we do not want would have been the wrong half
of the trade — this token lives in a PUBLIC repo.

ref=main is now explicit, which is also the more honest statement:
workflow_dispatch can only ever target a workflow file on the default
branch, so it is a fact about the dispatch rather than something to
rediscover at run time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JWriter20 JWriter20 closed this Aug 20, 2026
@JWriter20 JWriter20 reopened this Aug 20, 2026
@JWriter20

Copy link
Copy Markdown
Owner Author

✓ Tier 3 driver-gate — pass

Aggregate: 0.773 · 3/10 families passing · 20 pair(s) not solved

Port Pairs solved
js 0.773
python 0.773
Vendor family Solve rate
botdetect 0.000
geetest 0.714
hcaptcha 0.865
lemin 1.000
mtcaptcha 0.500
prosopo 1.000
recaptcha 0.500
tencent 0.000
yandex 0.000
yidun 1.000

@JWriter20
JWriter20 merged commit 4ef7581 into main Aug 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant