Releases: feder-cr/invisible_playwright_mcp
Release list
0.70.8
browser_type said "typed into" from having sent the keys, not from what the field kept. A field that a store rewrote a moment after the focus, one that took only digits, or one with a maxlength all answered "typed into" while holding something else; long text never finished inside the call; and two calls to the same browser interleaved their keys. Now every character is a real key at any length (text past what a call can type keeps going in the background, and the next action reports how it ended), input to one browser queues behind one lock, the pause between the focus and the first key is the wrapper's own (fill waits for it since invisible-playwright 0.25.8), and one reading of the field says what it kept: kept, reformatted, cut, emptied, only the tail, or focus moved on as in a one-time-code box. Nothing is retried, so a chip field gets one chip.
browser_upload_files attaches local files through the page's own file chooser: the visible input, button or label is clicked with the real pointer, a hidden input is opened through its label (or refused, naming the button to pass), and the files arrive after the session's hesitation rather than 60 ms later. It is off unless INVISIBLE_MCP_UPLOAD_DIRS lists absolute directories; each path is resolved, links included, and must sit inside one of them, hidden components and alternate data streams are refused, and per-call budgets apply. The engine gets a private copy that goes away with the session.
Co-authored-by: Richard Powell 1369873+richardpowellus@users.noreply.github.com
0.70.2
browser_navigate to YouTube, Reddit, or any page that replaces itself from
script while it loads waited 45 s for a navigation that was gone and answered
with an error on a page that was ready. Through this server's own tools, on
invisible-playwright 0.25.7: YouTube 4.3 s, Reddit 0.3 s, no error. A long
session could also freeze the browser for good once Firefox had written a pipe
buffer of output, since nothing read it after startup.
Both fixes are in invisible-playwright 0.25.7, and a Windows user with a
non-ASCII account name can now start the browser behind a proxy, from
invisible-core 34.31.0. The floor moves to 0.25.7 because a cached uvx
environment keeps what it resolved; the suite's core floor moves with it. No
code in this package changes.
0.70.1
Claude Desktop on Windows is an MSIX package. Files the server wrote under
AppData really landed in the package's own LocalCache folder, and the engine
downloaded there could not be started from the AppData path: the first
browser_open of every session answered [Errno 14001] ... side-by-side configuration is incorrect, and every tool after it said the browser was not
open. Measured by driving this server inside a packaged process with an
in-memory MCP client: with invisible-core 34.29.0 it fails exactly so, with
34.30.0 open, navigate, snapshot, type, click, read, screenshot and close all
work. Reported in invisible_playwright discussion #256.
The fix is in invisible-core 34.30.0, pinned by invisible-playwright 0.25.6.
The floor moves to 0.25.6 because a cached uvx environment keeps what it
resolved, and only a floor makes it resolve again; the suite's core floor
moves with it. No code in this package changes.
0.70.0
Both old names were deleted from the index on 2026-09-23 to free the new one:
aihawk and the shim that held invisible-playwright-mcp. Four endpoints answer
404 for both (JSON, simple, RSS, the file host) while invisible-playwright
answers 200 at the same moment, and PyPI accepted a pending publisher for the
new name, which it does only for a name that does not exist. So today
uvx invisible-playwright-mcp resolves to nothing, and this version is what
the README's instructions need to reach.
It also takes back a correction that was itself wrong. #1389 called the 404 a
transient, on the premise that PyPI does not let a deleted name be registered
again. It does, which is what made the rename possible; the deletion was real
and propagated unevenly. Three docstrings now say so.
The releases check learns the case it was missing. A deleted project answers
404 and so does an index having a bad minute, and nothing the index says can
tell them apart, so each distribution carries the day it was deleted, declared
like the boundary. Deleted ranges are not asked, and their tags are not
evidence that something should be served. The declaration is checked in the
one direction the index can answer: a project declared deleted that serves
versions fails.
The single-version guard is now a skip: the first release under the new name
is the only version on the index, and failing on that would be red on every
pull request until a second release, for nothing wrong.
Proven by three mutations, each red and naming its cause: aihawk declared
live, the boundary moved to 0.68.0, and a served project declared deleted.
0.69.2
The repository was renamed to carry the query it answers, mcp server, in
the one field GitHub search reads a brand from; the package on PyPI, the
name on the MCP Registry and uvx aihawk do not move. GitHub redirects the
old path for clones, raw files, the wiki and issues, but a README that
teaches a name which redirects is a fact gone stale under a green gate, so
this changes every occurrence at once: the three client install lines, the
six manifests and the marketplace files, the OpenRouter attribution URL, the
release-page test's repository, the content gate's fixtures, and 143 links
across the wiki and the articles.
0.69.2, because llm.py changed: the attribution OpenRouter groups usage by
is the URL, so the leaderboard entry starts afresh under the new one.
0.69.1
Three things changed this week for people who run AIHawk's browser from an assistant.
Claude Code and Codex take it as a plugin, Gemini CLI as an extension. Two lines for the first two, one for Gemini, and the setup skill comes along, so the assistant knows what to do when the browser engine is not there yet:
claude plugin marketplace add feder-cr/AIHawk
claude plugin install aihawk@feder-crcodex plugin marketplace add feder-cr/AIHawk
codex plugin add aihawk@feder-crgemini extensions install https://github.com/feder-cr/AIHawkThe engine downloads itself. Until 0.68 you ran uvx invisible-playwright fetch first, or the first browsing call sat there for minutes. Now the server starts the download the moment the client connects, and if you ask for a browser before it is done, browser_open answers with how far along it is and asks to be called again. No call waits on a transfer. uvx invisible-playwright fetch still exists, for watching the download in a terminal or redoing a failed one.
Gemini CLI installs this release from the archives attached below instead of cloning the repository. The wiki has a page per client: Claude Code, Codex, Gemini CLI, Claude Desktop, Cursor, Cline.
Two pages the README's commands were pointing at without a destination:
"Running AIHawk's browser from Codex" and "from Gemini CLI", in the shape of
the Claude Code one, listed in the guide and the index.
Gemini CLI installs the extension from the Latest release when it carries an
asset named for the platform, and clones the whole repository otherwise -
which is what it did on 2026-09-21, twelve megabytes of docs, tests and source
to deliver a manifest and a skill. Our releases carry two MCP bundles, so the
single-asset fallback never applies. scripts/pack_extension.py builds three
identical archives, one per platform name Gemini matches (darwin, linux,
win32), 22 KB each, listed against an allowed set before they ship, and the
bundle job attaches them beside the bundles. Measured with the packer; the
install from a release is measured at the first tag that carries them.
browser_open's first answer on a cold cache read "downloading now: 0 MB so
far", because the core says "downloading" before the first byte and before it
knows the size. That instant now reads as the download starting.
The test suite left two empty directories in TEMP per run, made at import
where no fixture teardown reaches; this machine had eighty-six. Removed at
exit, the throwaway cache last, after the guard has read it.
0.69.1, because engine.py changed.
0.69.0
Since 0.69.0 every server this suite spawns starts its engine download at
main(). The fast selection kept downloads away with a throwaway cache and a
one-second deadline, and on the ubuntu runner of the merge commit a complete
firefox-34 tree appeared in that cache anyway: the archive arrived inside the
second. The guard went red for the right reason, and main with it.
The prohibition is the core's own word for "there is nothing to download": a
LOCAL seal, one with no assets, on which ensure_binary refuses before it
touches the network or the cache. The conftest derives it from the packaged
seal - tag, version and Playwright range unchanged, the top-level build id
the host leg's, so a real binary named by STEALTHFOX_BINARY still verifies -
and points INVISIBLE_SEAL_FILE at it, inherited by every child process. It
does so without importing the core, because the light jobs install pytest
alone. The guard now asks ensure_binary and expects the refusal, instead of
reading the environment and hoping the network is slow.
0.68.10
Claude Code: the repository is now a plugin marketplace as well as a plugin
(.claude-plugin/marketplace.json, one entry, source ./), so the README
teaches claude plugin marketplace add feder-cr/AIHawk and claude plugin install aihawk@feder-cr where it taught claude mcp add. The plugin delivers
the server and the setup skill, and Claude Code counts it as an installation
of this plugin; a hand-registered server was counted by nobody. Measured: the
real-install test builds its throwaway marketplace from the shipped file with
only the name changed, and the client reports MCP servers (1), Skills (1).
claude plugin validate . passes without warnings.
Gemini CLI: gemini extensions install https://github.com/feder-cr/AIHawk
where the README taught gemini mcp add. The gallery had indexed the
extension for a week; the README was sending people the other way. Codex is
unchanged.
The marketplace name is declared in one file. marketplace_findings holds the
README's install line to it, and the fifth check of check_content.py now reads
the client lines from the README too: six pages went on teaching the old
lines with the gate green, because it read only the installer lines. A page
repeats the README's line, names the route bare in prose, or says nothing;
three mutations and two clean cases in the selftest. A plugin id
(aihawk@...) is no longer read as the launcher slot.
0.68.10, because the comment in server.py that explains why the server is
named stealth cited the README's line, which moved.
The engine fetch stays a step the person runs and watches, in the README's
block or when a browser tool says the engine is missing; nothing here makes
the server download it on its own.
0.68.9
The MCP handshake advertised 0.54.0 from a tree that was fully up to date at
0.68.8, and the interface served the same number as build.
__version__ came from importlib.metadata.version("aihawk"), which answers
about the distribution the installer put there. For a wheel that is the same
artifact as the code. For pip install -e the metadata is written once and the
code keeps moving, and nothing says the two have parted. Pulling does not touch
the record, so this is a defect in the code and not a stale checkout: measured
with the checkout at zero commits behind origin/main.
It was being advertised on the one field built to answer the question.
mcp/server.py sets serverInfo.version under a comment saying it exists so a
client can correlate a defect with a release, and that handshake had already
been wrong once, carrying the MCP SDK's version for every build of this
package. The remedy then replaced it with a number that is also not ours in the
install mode this project develops and tests in: the CI installs with
pip install -e ".[test]".
A test held it in place, and how it did is the part worth keeping. It asserted
that the string importlib.metadata appeared in the source. That is not
"derived, never typed"; it is one particular way of deriving, and it was the
wrong one. A test that pins a mechanism inherits whatever that mechanism gets
wrong. It now asserts the property, over the whole package rather than one
module.
So the version of the code is now: the install record for a normal install,
because the metadata and the code came out of the same build; the version the
source tree declares for an editable one, plus a +editable local segment so a
tree that can carry uncommitted work is never read as the published release of
the same number. Which of the two an install is comes from what the installer
wrote (PEP 610 direct_url.json), not from a guess about __file__.
The record stays, under a name that cannot be mistaken for the version.
invisible_core made this same split first, deriving from the seal it ships
and naming the record __install_record_version__; this is that decision in
the package that needed it next.
And aihawk.mcp no longer runs its own lookup beside the parent's. Two
computations of one fact are free to disagree the day one of them changes,
which is the defect the comment above them was already complaining about.
Verified on the real surface, one variable: the handshake answers 0.54.0 from
the old code and 0.68.8+editable from this one. Six known-bad mutations, six
killed, including the whole previous implementation put back and a wheel arm
that keeps the remedy from consulting a tree that is not what runs.
0.68.8
Deleting the default conversation from the sessions panel killed the whole
interface. Measured over plain HTTP, no browser, no page:
/sessions/forget on any other conversation -> 200, interface alive
/sessions/forget on the DEFAULT -> 200, process DEAD, exit 1
Link.open called __aenter__ on stdio_client and on ClientSession by
hand, and Link.close called __aexit__ on them - from whatever task happened
to be closing. Both are anyio context managers, so each owns a cancel scope,
and a cancel scope has to be exited in the task that ENTERED it. Exiting it
elsewhere delivers the cancellation to the scope enclosing the entering task.
cli.serve opens the default conversation and then runs uvicorn in the same
task, so closing that link from a request task cancelled server.serve().
And the others were not fine, they were quiet. Their scopes belong to request
tasks that had already finished, so the same wrong exit raised
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in, which close was swallowing under a sentence written about
teardown failures. EVERY close was wrong; exactly one of them had something
alive to damage. Making only the default lazy would have removed the visible
half and kept the defect.
So the connection has an OWNER. One task opens it, publishes it, waits to be
told to stop, and closes it itself; open starts that task and waits until the
connection is usable, close asks it to let go and waits until it has. Closing
from another task is not guarded against - it is made impossible, because no
other task ever holds the contexts. _ctx and _sess_ctx are gone with it.
A failure BEFORE the connection is usable is now open's to report rather than
to swallow: it is the difference between being told the server did not start
and a page waiting for one that never will.
Six tests against the real server over stdio, because the defect is about task
ownership of a real transport and a double cannot have it. Five known-bad
inputs, all killed: the whole previous implementation put back (three of the
six go red with the cancel-scope sentence verbatim), an owner that does not
wait, an open that cannot report a failure, a close that does not wait for the
owner, and a close that does not even ask it to stop.
Two of those five SURVIVED the first draft of the tests, and both survivals
were real holes rather than bad mutations. Nothing asserted that an open
connection ANSWERS, so a link that closed itself the instant after it was
published read exactly like a live one; and the failed-open arm accepted any
exception, including the TimeoutError that means it hung, which is the very
thing it exists to forbid. Both are fixed in the tests, not in the code.
mcp/session.py has the same SHAPE on InvisiblePlaywright - __aenter__ in
start, __aexit__ in close - and was checked rather than assumed: no file in
invisible_playwright imports anyio, so there is no cancel scope there and no
such failure to have. Written down because an audit that names only the broken
places does not say how much it looked at.
Verified on the real product: deleting the default conversation now leaves the
interface alive and /sessions answering 200.
Suite: 738 passed, 9 skipped. Lint, invisible_core.english and
check_content.py clean.