| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
One page per browser, no session concept, and the names to match (#1301) 24 tools before, 16 now. No tool takes `session_id`; which piece of work a server is comes from `AIHAWK_SESSION_ID`, read once at spawn. The four tab tools are gone - a browser drives one page, and the answer to "I need a second page" is the `support` browser, which is a better answer because a second tab inside `main` carries that identity's cookies to the second site. One server process per conversation instead of one connection multiplexed by an id. Bugs found and fixed along the way, each with the measurement that found it: - The wake reopened every saved url as its own page, and `browser_status` then blamed the site for pages the wake had opened. - The interface and the server could silently address different files: two declarations of `"default"`, guarded by an assertion comparing one of them to a literal. - The key rule had two implementations and the tested one was dead. - A test in the fast CI job downloaded 665 MB of Firefox, and four pushes hung to GitHub's six-hour ceiling without ever going red: a job that hangs reports `in_progress`. Jobs have a ceiling now and a hung test dumps its stack. - A unit test served the interface forever, because its brake pointed at a name the command had stopped reading. It was green locally only because the developer's own interface held the port. - A reopened conversation lost the answer of every turn but the last. - The session drawer moved the whole window 48px and rewrapped the transcript, then covered half the composer, then still covered it below 720px where the panes stack. - A page older than its server degraded in silence; it says so now. - The English gate had never looked at `.js`, `.css` or `.html` - 67 KB of served page - while five front-end files carried Italian. - Two fetches were outside the one funnel that knows what a 404 and a 410 mean. `ruff --select F` is a CI job now, earned by measurement: with an import dropped the suite stays fully green and ruff finds it in under a second. 556 passed, 8 skipped, 47 deselected. Every new gate was run against its own known-bad and green again on restore. Five of them EXECUTE the thing they guard rather than scanning it, because a scan cannot tell whether a timer is reached, what a function returns, or where a box ends up on screen. | 9 天前 | |
A connection is closed by the task that opened it, or it takes the interface with it (#1368) 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. | 3 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
Open first: browser_open is the only tool that opens a browser (0.53.0) (#1333) The lifecycle had seven steps - declare, wake, look, act, rebuild, remember, close - because a command aimed at a browser that was not running STARTED one, and an action whose browser had died REBUILT it and ran the action again. Each was a place to be wrong, and both were. The owner cut it on 2026-09-14. What is left: - `browser_open` opens a browser and nothing else does. With no arguments `main` is the person this session already was: seed, exit and profile read from the file, which `browser_open` writes and which nothing else touches. Any argument is a decision and goes through the planner. - Every other tool goes through one funnel, `Work.acting`. A browser that is not open answers `the main browser is not open. Call browser_open to open it.`; one that was open and died answers `the main browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person.` and is forgotten, so the next open starts clean. A closed target raised under an action is the same fact and the same sentence. A page that refuses passes through untouched. - `browser_close` frees the engine and keeps who it was. - The live pane reads the not-open sentence as idle and every other sentence as something to say. What died with it: `registry.py` (367 lines - locks per key, refusals, tab numbering across rebuilds, ensure/drop/declare/on_change/is_dead), half of `work.py` (ready, looking, already_open, retrying, the pages owed and seen, three sentences for "nothing running"), the tab bookkeeping in `session.py` (ids, counter, active flag, close_page: a browser drives one page, the newest live one of its context), and the strip of other browsers in the page with its stylesheet, empty by construction once nothing is "declared and not started". Nine test files of the old lifecycle are one file of the new one. The model's instructions open with it, and so does the system prompt. Driven with the real model from this tree: asked to read a heading with nothing open, it called browser_open first, then navigated. The page loads with no script error and one screen. Eight mutations, seven killed and one equivalent (remember called for the helper writes nothing either way). Suite 584; engine tests 32 green on firefox-30. | 7 天前 | |
The product surface is what the product uses (0.67.0) (#1351) An audit for code that exists only because the tests grew up around it. Seven surfaces came out of src/aihawk and every one had the same shape: zero callers in the product, and enough callers in the suite to look load-bearing. AGENT.RUN_TASK WAS A SECOND WAY TO RUN THE AGENT LOOP. Its own docstring said so: "not called by the product - kept because the suite drives the loop through it, about twenty-five tests". Worse than unused, it took an object with list_tools and call_tool, which is the shape from before Link existed, while the product passes link.call and link.tools to Conversation.run. A reader of agent.py met two entry points with two different ideas of how a tool is reached, and one of them was imaginary. The convenience was real, so it moved to tests/_loop.py rather than being deleted. The e2e test that drove a real server through it now goes through Conversation.run, which is the path the product takes. SESSIONS.AROUND OFFERED ITSELF TO "ANYTHING EMBEDDING THIS", a user that does not exist: this is an application, and the only importer of sessions.py is cli.py. Eleven callers, all in two test modules. The argument it was written for is good and survives in tests/_sessions.py, because it is a rule about the suite: a test driving ONE conversation should still go through build_app and the routes, so the single case is exercised by the same code the many case uses. SESSIONS._OPEN_LINK WAS A SEAM NOTHING DECLARED. It was assigned in __init__ and reassigned from outside by two test modules - a convention a reader of the signature could not see and a reader of the assignment could not tell from an accident. It is a keyword argument now, so how a connection is made is part of the interface and the default is the product's one way. SESSIONPLAN.DESCRIBE WAS A SECOND MAPPING OF A PLAN ONTO THE SENTENCE a caller is told, beside the one in work.open, and the two were free to disagree the day a fifth field joined the sentence. It also described something that does not happen: between planning and launching, work.open can rewrite the settings and the exit note - that is how the helper browser comes to share main's exit - so the sentence is read from what was LAUNCHED and never from what was planned. A method on the plan quietly offered the other thing. Its six callers were all in one test file and now go through the function the product goes through. CLEAN.RELEVANCE AND CLEAN.CLEAN_STATS ARRIVED DEAD and stayed that way. Fifty seven lines scoring how much a model needs an element, and seven figures about what the cleaning saved, both imported with the server on 2026-09-06 and named by no commit since. clean_stats was in __all__ and had a test; relevance had neither, and the module docstring described it as part of how the file works - "the relevance score below orders and annotates" - so the file's own account of itself named a pass that does not run. What those two were really carrying is kept: that nothing here may drop an element, and that a reduction figure says nothing about whether the result is still usable. Both are the invariant at the top of the module, which is where a rule can act. CLEAN.BLOCK_TAGS was a tuple nothing read. TWO PLACES ALSO STOPPED HOLDING THE SAME FACT TWICE. storage.file_for owns the rule that turns a session id into a path, so chats.py and mcp/store.py each name only their own directory and neither spells the join. And a test migrated off the store.home re-export onto aihawk.storage.home, which is where home lives. THE GATE IS ON THE CLASS, NOT ON THE SEVEN NAMES. A list would stop those seven; the defect is that a surface can be added, be used only by its own tests, and look exactly like one the product depends on. So it asks the general question: every top-level function, class and constant in src/aihawk must be named somewhere in src/aihawk. Being registered excuses it, and that is structural - a decorator spelled .tool, .command or .group hands the object to FastMCP or to click, where the call happens over a wire or from a shell. Wrapping does not: dataclass and contextmanager hand the object straight back, so it is still called by name. Exporting it in __all__ does not count either, because __all__ is the claim under audit rather than evidence for it, and that is precisely where clean_stats was hiding. WHAT THE GATE DOES NOT SEE IS WRITTEN INTO IT. 178 definitions across 25 modules, with floors asserted so a scan that goes blind fails instead of printing the same clean line. Methods are out of scope: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so no scan can tell the dead one from the live one beside it. That one was found by reading, and this gate would not have found it. Suite 654 green, from a 642 baseline: one test went with clean_stats and thirteen arrived with the gate. Six known-bad inputs and five cases that must not fire, plus a mutation against the real tree - relevance put back into clean.py, which the gate names by file and line. The first three must-not-fire cases failed on the first run and the gate was right: a toy module whose own outermost function has no caller has a dead surface, so each fixture ends at a registered entry point, exactly as the package does. | 6 天前 | |
Open first: browser_open is the only tool that opens a browser (0.53.0) (#1333) The lifecycle had seven steps - declare, wake, look, act, rebuild, remember, close - because a command aimed at a browser that was not running STARTED one, and an action whose browser had died REBUILT it and ran the action again. Each was a place to be wrong, and both were. The owner cut it on 2026-09-14. What is left: - `browser_open` opens a browser and nothing else does. With no arguments `main` is the person this session already was: seed, exit and profile read from the file, which `browser_open` writes and which nothing else touches. Any argument is a decision and goes through the planner. - Every other tool goes through one funnel, `Work.acting`. A browser that is not open answers `the main browser is not open. Call browser_open to open it.`; one that was open and died answers `the main browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person.` and is forgotten, so the next open starts clean. A closed target raised under an action is the same fact and the same sentence. A page that refuses passes through untouched. - `browser_close` frees the engine and keeps who it was. - The live pane reads the not-open sentence as idle and every other sentence as something to say. What died with it: `registry.py` (367 lines - locks per key, refusals, tab numbering across rebuilds, ensure/drop/declare/on_change/is_dead), half of `work.py` (ready, looking, already_open, retrying, the pages owed and seen, three sentences for "nothing running"), the tab bookkeeping in `session.py` (ids, counter, active flag, close_page: a browser drives one page, the newest live one of its context), and the strip of other browsers in the page with its stylesheet, empty by construction once nothing is "declared and not started". Nine test files of the old lifecycle are one file of the new one. The model's instructions open with it, and so does the system prompt. Driven with the real model from this tree: asked to read a heading with nothing open, it called browser_open first, then navigated. The page loads with no script error and one screen. Eight mutations, seven killed and one equivalent (remember called for the helper writes nothing either way). Suite 584; engine tests 32 green on firefox-30. | 7 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
Each number declared once, and evidence kept beside the code it justifies (0.65.0) (#1349) Three more copies, found by scanning for measured numbers written in more than one file rather than by reading. THE READ_TEXT CAP WAS WRITTEN THREE TIMES: DEFAULT_MAX_CHARS in actions.py, the default in browser_read_text's signature, and the digit in its description. Nothing read the signature back, so it could drift from the constant in silence and the tool would honour a number its own module did not declare. It reads the constant now. The digit in the prose STAYS, and a draft that deleted it was wrong. The reasoning was that the schema already publishes "default": 6000 so the sentence is a second copy - true, and not the whole story: test_the_two_readers_do_not_pretend_to_share_a_cap holds that this description declares its cap while browser_read_html declares it has none, because a reader shown one and not the other assumes symmetry, and the two differ by 34x on a large page. A number with a reason to be written twice gets a gate rather than a deletion, so the prose digit is now tied to the constant and cannot drift. THE SELECTOR STUDY AND THE OPTION COUNT WERE IN TWO PLACES EACH. 958 elements, 88% addressable and 48% unambiguous, two hundred option nodes: all of it also written in actions.py beside the code it explains. A measurement in two places is one that gets re-run once and updated once, and the copy left wrong is the one a model reads - out of the 1024 characters the description is cut at. The evidence stays in the module; the description keeps every rule those paragraphs carried, and a gate holds both halves so dropping the numbers cannot quietly drop a rule with them. THE THREE-VALUED RULE WAS SPELLED OUT IN BOTH RESOLVERS, in two different shapes, with the second one's docstring saying "same three-way rule as the profile" - a claim a reader had to go and verify, and one that stops being true the moment either is edited. It is `_asked` now, said once. AND A SURVIVING MUTATION FOUND A HOLE THAT WAS ALREADY THERE. Restricting the STEALTHFOX_NO_PROXY veto to the environment's own answer looked like a behaviour change and is not: the old code got the same result from the ORDER of two returns, the explicit branch returning before the veto was read. Dropping the guard changed nothing any test could see. That a caller's proxy beats the variable is now held by a test, which is the use of a known-bad input beyond judging the change: it finds what the suite never looked at. The published figures move with the descriptions, so fourteen pages carry 8,040 characters and 3,141 tokens now. Suite 638 green. Eight known-bad inputs: seven killed, one expected to survive and reported as such - rewriting the profile resolver back to its old spelling is an equivalent rewrite, and a gate that went red on it would be holding the shape rather than the behaviour. | 6 天前 | |
The model closes support because it finally reads the rule that says to (0.42.0) (#1303) The owner watched the helper browser get opened, used and never closed. Measured before touching anything, with the real model on a task that needs both browsers: six runs out of six left `support` open at the end, and in the owner's own sessions one conversation of 247 steps used it 29 times and never closed it. Three origins, none of them the model: - The server's instructions never reached it. They are the one place the two-browser contract is written - what `support` is for, and that whoever opens it closes it - and the loop sent the system prompt and the tool descriptions and nothing else. The link keeps what the server says at `initialize`, and one function builds the system message from this build's prompt plus those instructions, refreshed at the start of every run, so a restored transcript and a fresh one carry the same current text. It was assembled by hand in two places and had the instructions in neither. - The end-of-turn sentence forbade the very call that closes the helper: "reply with the answer and do NOT call any more tools", at the exact moment the close should happen. The order is in the sentence now - close `support` if you opened it and nothing more needs it, then answer, then no more tools - without exception vocabulary, because an exception is what a model reaches for. - The API cuts every tool description at 1024 characters, and `browser_open` was 1996: the model read it ending mid-word inside the paragraph about profiles, and the one sentence about closing was past the cut. `browser_click_at` was over too. Both fit now, the contract comes first, and what was cut moved into comments for a reader of the source. Measured after, same bench, same task, same model, six runs: `support` closed before the answer in six, left open at the end in none. Every sequence ends with browser_close support. Gates: every description under the limit and identical to what the loop sends, executed against the real server; `browser_open` names `browser_close` in its first half; the loop puts the instructions in the one system message and replaces them run to run; the brain hands the link's instructions to the loop; the end-of-turn sentence closes before it answers and carries no loophole. | 9 天前 | |
Open first: browser_open is the only tool that opens a browser (0.53.0) (#1333) The lifecycle had seven steps - declare, wake, look, act, rebuild, remember, close - because a command aimed at a browser that was not running STARTED one, and an action whose browser had died REBUILT it and ran the action again. Each was a place to be wrong, and both were. The owner cut it on 2026-09-14. What is left: - `browser_open` opens a browser and nothing else does. With no arguments `main` is the person this session already was: seed, exit and profile read from the file, which `browser_open` writes and which nothing else touches. Any argument is a decision and goes through the planner. - Every other tool goes through one funnel, `Work.acting`. A browser that is not open answers `the main browser is not open. Call browser_open to open it.`; one that was open and died answers `the main browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person.` and is forgotten, so the next open starts clean. A closed target raised under an action is the same fact and the same sentence. A page that refuses passes through untouched. - `browser_close` frees the engine and keeps who it was. - The live pane reads the not-open sentence as idle and every other sentence as something to say. What died with it: `registry.py` (367 lines - locks per key, refusals, tab numbering across rebuilds, ensure/drop/declare/on_change/is_dead), half of `work.py` (ready, looking, already_open, retrying, the pages owed and seen, three sentences for "nothing running"), the tab bookkeeping in `session.py` (ids, counter, active flag, close_page: a browser drives one page, the newest live one of its context), and the strip of other browsers in the page with its stylesheet, empty by construction once nothing is "declared and not started". Nine test files of the old lifecycle are one file of the new one. The model's instructions open with it, and so does the system prompt. Driven with the real model from this tree: asked to read a heading with nothing open, it called browser_open first, then navigated. The page loads with no script error and one screen. Eight mutations, seven killed and one equivalent (remember called for the helper writes nothing either way). Suite 584; engine tests 32 green on firefox-30. | 7 天前 | |
What nobody called is gone, and browser_open answers with the plan it made (0.52.0) (#1327) * What nobody called is gone, and browser_open answers with the plan it made Removals, each of them measured to have no caller in the product: - `build_app` took a `link` it never read; every caller passed one anyway. - `_json_capped` in server.py was an alias of `actions.json_capped` kept for importers that did not exist outside one test. - `actions.watch_jpeg` forwarded to `session.watch_frame()` and nothing else; the tool calls the session. - `work.remember()` after `restart` and after `forget` in server.py: the registry already reports both through `on_change`, so each write was two. - `session.new_page()` before the owed-page replay: `actions.navigate` opens the tab itself when there is none. - `label = mdl` in the CLI. - The registry's late import of the planner, with a comment about a cycle that does not exist. Two things said better: - `browser_open` answers with the plan it made instead of re-deriving a sentence from what the registry holds. The difference is everything the planner attached and the launch does not carry: where the seed came from, and the warning that a profile is returning through another exit. That warning was computed and dropped on the floor. - Five sentences saying "nothing is running" are two constants. The two that promised "the next tool that needs a page will open one" are gone: a read has not opened a browser since 0.50.0, and telling a model to retry the read it just made is the one thing that cannot work. And the exit hook is registered only over HTTP, where the lifespan cannot close the browsers. Over stdio the lifespan closes them, and an atexit hook on top ran in a new loop where a Playwright object from the finished one never answers. The test that scanned the module for the register line is now two behavioural tests of `main()`, one per transport, each killed by its own mutation. `Sessions.knows` asks whether the files exist instead of parsing them: `get` reads the same file right after, so every request parsed it twice. * 0.52.0, because the version gate refuses shipped files under a published number | 7 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
One declaration of what a caller can act on, and one of what `browser` means (0.64.0) (#1348) Three things the prompt side said more than once, and the first of them was not only repetition: the two copies disagreed. WHAT IS INTERACTIVE WAS DECLARED TWICE, IN TWO LANGUAGES. clean.py held nineteen roles for the sieve; the snapshot's selector held seven, typed by hand inside a JavaScript string in another module, where nothing could compare them. Measured on a page of ARIA controls, driven through the real server: a role=combobox and a role=slider with no tabindex came back from browser_read_html and were absent from browser_snapshot, which is the tool the instructions name as the way to find something to click. Seven controls in all, invisible to the rung the ladder starts on, while the sieve saw every one. The snapshot now asks the page for the selector clean.py builds, so there is one list. The one difference the two need is NAMED instead of being left to a second list to imply: roles whose members come in hundreds - option, gridcell - are kept by the sieve and left out of the inventory, which is the measurement the snapshot's own docstring already carried about a country select with two hundred options. Before and after on the same engine: 8 elements to 15, seven gained, none lost, and a thirty-item listbox still contributes one row. The selector is joined by concatenation and not substituted into the script. That block holds 12 literal percent signs, and a placeholder searched for inside code is found inside the caller's code too - which is the defect fixed one layer down yesterday. THE SAME SENTENCE ABOUT `browser` CLOSED THIRTEEN DESCRIPTIONS, word for word. It is on the parameter now, said once. It also spent the wrong budget: a description is cut at 1024 characters before the model reads it and browser_open sat at 1021, three characters from losing the sentence that says who closes support, which is the defect its own gate exists for. It is 1004 now, and the room went to two rules that had been cut for space and left in a comment no model would ever read. AND THE FIRST VERSION OF THAT MOVE WAS A LOSS, which only a measurement said. A schema travels with its tool every turn exactly as a description does, so thirteen copies in the schemas is the same duplication moved, and the four-sentence version made it dearer: descriptions -256 tokens, schemas +900, complete definitions +617, sixteen percent MORE per turn for a change whose point was to spend less. The parameter carries one line now and the whole definition is -13 tokens, which is to say the same cost. The saving was never the point; not saying it thirteen times is. THE RULE ABOUT OPENING FIRST WAS IN BOTH TEXTS THAT MAKE ONE MESSAGE. The loop's prompt opened with it and the server's instructions say it in their first paragraph, glued on a few lines below: the model read it twice. It belongs to the server, because it is a fact about the tools and a standalone client that never sees the loop's prompt still has to be told it. A gate holds that the home is not empty, which is the half that matters when a sentence moves. A draft also made an empty instructions string append a warning for the model. It was dropped: the link launches this very server, so arriving with nothing means the handshake failed, and a sentence in the prompt helps nobody in that state. Four tests said so before I did. AND THE FIGURE MOVED IN FOURTEEN PUBLISHED PAGES. 9,097 characters became 8,105, and the token counts with it, across 21 occurrences that a content gate checks in only one of its four numbers. Of the fourteen pages, TWO published the token figure with no character figure beside it, so on those two the neighbour the gate relies on protected nothing. Both were given the figure they were missing, and the gate now holds the arrangement rather than hoping for it, which needs no tokenizer: it checks that the number it CAN verify is present. Suite 632 green. Eleven known-bad inputs, eleven killed, plus the gate's own selftest at 18 mutations and 13 clean cases. One of the eleven survived the first run and the gate was genuinely blind: it asked whether anything in the excluded set was inventoried, which a mutation satisfies by moving the role into the other set. The two names are written out now. Another was killed by the parser rather than by the gate, which is not a kill; rewritten as valid Python, the gate kills it. | 6 天前 | |
The product surface is what the product uses (0.67.0) (#1351) An audit for code that exists only because the tests grew up around it. Seven surfaces came out of src/aihawk and every one had the same shape: zero callers in the product, and enough callers in the suite to look load-bearing. AGENT.RUN_TASK WAS A SECOND WAY TO RUN THE AGENT LOOP. Its own docstring said so: "not called by the product - kept because the suite drives the loop through it, about twenty-five tests". Worse than unused, it took an object with list_tools and call_tool, which is the shape from before Link existed, while the product passes link.call and link.tools to Conversation.run. A reader of agent.py met two entry points with two different ideas of how a tool is reached, and one of them was imaginary. The convenience was real, so it moved to tests/_loop.py rather than being deleted. The e2e test that drove a real server through it now goes through Conversation.run, which is the path the product takes. SESSIONS.AROUND OFFERED ITSELF TO "ANYTHING EMBEDDING THIS", a user that does not exist: this is an application, and the only importer of sessions.py is cli.py. Eleven callers, all in two test modules. The argument it was written for is good and survives in tests/_sessions.py, because it is a rule about the suite: a test driving ONE conversation should still go through build_app and the routes, so the single case is exercised by the same code the many case uses. SESSIONS._OPEN_LINK WAS A SEAM NOTHING DECLARED. It was assigned in __init__ and reassigned from outside by two test modules - a convention a reader of the signature could not see and a reader of the assignment could not tell from an accident. It is a keyword argument now, so how a connection is made is part of the interface and the default is the product's one way. SESSIONPLAN.DESCRIBE WAS A SECOND MAPPING OF A PLAN ONTO THE SENTENCE a caller is told, beside the one in work.open, and the two were free to disagree the day a fifth field joined the sentence. It also described something that does not happen: between planning and launching, work.open can rewrite the settings and the exit note - that is how the helper browser comes to share main's exit - so the sentence is read from what was LAUNCHED and never from what was planned. A method on the plan quietly offered the other thing. Its six callers were all in one test file and now go through the function the product goes through. CLEAN.RELEVANCE AND CLEAN.CLEAN_STATS ARRIVED DEAD and stayed that way. Fifty seven lines scoring how much a model needs an element, and seven figures about what the cleaning saved, both imported with the server on 2026-09-06 and named by no commit since. clean_stats was in __all__ and had a test; relevance had neither, and the module docstring described it as part of how the file works - "the relevance score below orders and annotates" - so the file's own account of itself named a pass that does not run. What those two were really carrying is kept: that nothing here may drop an element, and that a reduction figure says nothing about whether the result is still usable. Both are the invariant at the top of the module, which is where a rule can act. CLEAN.BLOCK_TAGS was a tuple nothing read. TWO PLACES ALSO STOPPED HOLDING THE SAME FACT TWICE. storage.file_for owns the rule that turns a session id into a path, so chats.py and mcp/store.py each name only their own directory and neither spells the join. And a test migrated off the store.home re-export onto aihawk.storage.home, which is where home lives. THE GATE IS ON THE CLASS, NOT ON THE SEVEN NAMES. A list would stop those seven; the defect is that a surface can be added, be used only by its own tests, and look exactly like one the product depends on. So it asks the general question: every top-level function, class and constant in src/aihawk must be named somewhere in src/aihawk. Being registered excuses it, and that is structural - a decorator spelled .tool, .command or .group hands the object to FastMCP or to click, where the call happens over a wire or from a shell. Wrapping does not: dataclass and contextmanager hand the object straight back, so it is still called by name. Exporting it in __all__ does not count either, because __all__ is the claim under audit rather than evidence for it, and that is precisely where clean_stats was hiding. WHAT THE GATE DOES NOT SEE IS WRITTEN INTO IT. 178 definitions across 25 modules, with floors asserted so a scan that goes blind fails instead of printing the same clean line. Methods are out of scope: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so no scan can tell the dead one from the live one beside it. That one was found by reading, and this gate would not have found it. Suite 654 green, from a 642 baseline: one test went with clean_stats and thirteen arrived with the gate. Six known-bad inputs and five cases that must not fire, plus a mutation against the real tree - relevance put back into clean.py, which the gate names by file and line. The first three must-not-fire cases failed on the first run and the gate was right: a toy module whose own outermost function has no caller has a dead surface, so each fixture ends at a registered entry point, exactly as the package does. | 6 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
One page per browser, no session concept, and the names to match (#1301) 24 tools before, 16 now. No tool takes `session_id`; which piece of work a server is comes from `AIHAWK_SESSION_ID`, read once at spawn. The four tab tools are gone - a browser drives one page, and the answer to "I need a second page" is the `support` browser, which is a better answer because a second tab inside `main` carries that identity's cookies to the second site. One server process per conversation instead of one connection multiplexed by an id. Bugs found and fixed along the way, each with the measurement that found it: - The wake reopened every saved url as its own page, and `browser_status` then blamed the site for pages the wake had opened. - The interface and the server could silently address different files: two declarations of `"default"`, guarded by an assertion comparing one of them to a literal. - The key rule had two implementations and the tested one was dead. - A test in the fast CI job downloaded 665 MB of Firefox, and four pushes hung to GitHub's six-hour ceiling without ever going red: a job that hangs reports `in_progress`. Jobs have a ceiling now and a hung test dumps its stack. - A unit test served the interface forever, because its brake pointed at a name the command had stopped reading. It was green locally only because the developer's own interface held the port. - A reopened conversation lost the answer of every turn but the last. - The session drawer moved the whole window 48px and rewrapped the transcript, then covered half the composer, then still covered it below 720px where the panes stack. - A page older than its server degraded in silence; it says so now. - The English gate had never looked at `.js`, `.css` or `.html` - 67 KB of served page - while five front-end files carried Italian. - Two fetches were outside the one funnel that knows what a 404 and a 410 mean. `ruff --select F` is a CI job now, earned by measurement: with an import dropped the suite stays fully green and ruff finds it in under a second. 556 passed, 8 skipped, 47 deselected. Every new gate was run against its own known-bad and green again on restore. Five of them EXECUTE the thing they guard rather than scanning it, because a scan cannot tell whether a timer is reached, what a function returns, or where a box ends up on screen. | 9 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
The language gate comes from the core, and this repository exempts nothing * The language gate comes from the core, and this repository exempts nothing invisible-core 30.23.0 carries the check that a public repository is English, as `invisible_core.english`. Until now it was a script copied here from the wrapper, and the copy carried the wrapper's data with it: four of the five paths in its exclusion list - `src/invisible_playwright/_pw/`, `_driver/`, `_juggler/injected.js`, `tests/test_fork.py` - do not exist in this repository and never did. A dead exclusion never makes anything red, which is exactly why nobody found it; the shared gate refuses one. So `scripts/check_english_only.py` is deleted, the `english` CI job installs the core and runs the module, and the tool-description test imports the word list from `invisible_core.english` instead of loading the script by path. This repository declares no `[tool.invisible.english]` at all: measured, the shared gate reads 245 tracked files here and is silent with nothing exempt. The dependency floor moves to `invisible-playwright>=0.16.2`, which is the first wrapper pinning a core that has the module the test imports. * The version is declared in three files, and the suite declares what it imports Three things the CI found on the first run, all of them mine and all of them real. The version lives in `pyproject.toml`, `manifest.json` and `server.json`, and bumping one left two behind: the gate compares them and said so. Bumped. `invisible-core` is now a DECLARED test dependency. It was already arriving through `invisible-playwright`, so nothing was missing at runtime - but since 30.23.0 the language gate is `invisible_core.english` and two test files import it directly, and an import that leans on a transitive dependency works until the day the wrapper stops needing the core. `test_the_suite_declares_everything_it _imports` caught it against the published stack, which is what it is for. And `test_the_gate_says_which_tree_it_judged.py` is rewritten against the module instead of the deleted script. Its own docstring had named this consolidation and said it was not done yet - "the single home for a rule that all three must obey is invisible_core ... that consolidation is a core release and is not done here". It is done, so the test that asserted the REFUSAL is gone: the script could only judge where it lived, so pointing it elsewhere was a mistake to catch; the module takes the tree as an argument, so pointing it elsewhere is an ordinary request. What replaces it is the property the refusal was protecting - a cross-repo run answers about the tree it was given and names it, and pointed at a guilty tree it accuses THAT one rather than reporting its own clean state. The file also pins what the copy cost: four of the five exclusions it carried named paths that only ever existed in the wrapper, and this repository now declares none. Suite 693 passed against the published stack - invisible-core 30.23.0 and invisible-playwright 0.16.2 resolved from the index, in a clean venv. ruff clean, language gate clean on 245 files with nothing exempt. | 5 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
Open first: browser_open is the only tool that opens a browser (0.53.0) (#1333) The lifecycle had seven steps - declare, wake, look, act, rebuild, remember, close - because a command aimed at a browser that was not running STARTED one, and an action whose browser had died REBUILT it and ran the action again. Each was a place to be wrong, and both were. The owner cut it on 2026-09-14. What is left: - `browser_open` opens a browser and nothing else does. With no arguments `main` is the person this session already was: seed, exit and profile read from the file, which `browser_open` writes and which nothing else touches. Any argument is a decision and goes through the planner. - Every other tool goes through one funnel, `Work.acting`. A browser that is not open answers `the main browser is not open. Call browser_open to open it.`; one that was open and died answers `the main browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person.` and is forgotten, so the next open starts clean. A closed target raised under an action is the same fact and the same sentence. A page that refuses passes through untouched. - `browser_close` frees the engine and keeps who it was. - The live pane reads the not-open sentence as idle and every other sentence as something to say. What died with it: `registry.py` (367 lines - locks per key, refusals, tab numbering across rebuilds, ensure/drop/declare/on_change/is_dead), half of `work.py` (ready, looking, already_open, retrying, the pages owed and seen, three sentences for "nothing running"), the tab bookkeeping in `session.py` (ids, counter, active flag, close_page: a browser drives one page, the newest live one of its context), and the strip of other browsers in the page with its stylesheet, empty by construction once nothing is "declared and not started". Nine test files of the old lifecycle are one file of the new one. The model's instructions open with it, and so does the system prompt. Driven with the real model from this tree: asked to read a heading with nothing open, it called browser_open first, then navigated. The page loads with no script error and one screen. Eight mutations, seven killed and one equivalent (remember called for the helper writes nothing either way). Suite 584; engine tests 32 green on firefox-30. | 7 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
Open first: browser_open is the only tool that opens a browser (0.53.0) (#1333) The lifecycle had seven steps - declare, wake, look, act, rebuild, remember, close - because a command aimed at a browser that was not running STARTED one, and an action whose browser had died REBUILT it and ran the action again. Each was a place to be wrong, and both were. The owner cut it on 2026-09-14. What is left: - `browser_open` opens a browser and nothing else does. With no arguments `main` is the person this session already was: seed, exit and profile read from the file, which `browser_open` writes and which nothing else touches. Any argument is a decision and goes through the planner. - Every other tool goes through one funnel, `Work.acting`. A browser that is not open answers `the main browser is not open. Call browser_open to open it.`; one that was open and died answers `the main browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person.` and is forgotten, so the next open starts clean. A closed target raised under an action is the same fact and the same sentence. A page that refuses passes through untouched. - `browser_close` frees the engine and keeps who it was. - The live pane reads the not-open sentence as idle and every other sentence as something to say. What died with it: `registry.py` (367 lines - locks per key, refusals, tab numbering across rebuilds, ensure/drop/declare/on_change/is_dead), half of `work.py` (ready, looking, already_open, retrying, the pages owed and seen, three sentences for "nothing running"), the tab bookkeeping in `session.py` (ids, counter, active flag, close_page: a browser drives one page, the newest live one of its context), and the strip of other browsers in the page with its stylesheet, empty by construction once nothing is "declared and not started". Nine test files of the old lifecycle are one file of the new one. The model's instructions open with it, and so does the system prompt. Driven with the real model from this tree: asked to read a heading with nothing open, it called browser_open first, then navigated. The page loads with no script error and one screen. Eight mutations, seven killed and one equivalent (remember called for the helper writes nothing either way). Suite 584; engine tests 32 green on firefox-30. | 7 天前 | |
Two gates that execute where they used to read (#1338) Tests only. Nothing that ships moves, so there is no version to bump. THE HANDLE LOGIC. SNAPSHOT_JS is 220 lines of JavaScript inside a Python string, and the part of it that builds a selector decides where every click this product makes lands. Its behaviour was proven in exactly one place, test_snapshot_handles.py, against a real Firefox and behind an e2e marker that the default selection deselects. So on an ordinary run, and on every pull request that does not pay for an engine, "prefer an id, then a name, then an href" was held up by regexes over the source text - which can see that a branch exists and never what it returns. node is on this machine and on every runner, and three of this suite's page gates already use it. The handle half is pure: attributes in, a selector string out, with one question asked of the document. That fake is small enough to print. The visibility half is not and stays e2e, because it reads layout through getBoundingClientRect and getComputedStyle and a fake DOM for that would be fiction rather than a test. The docstring says so rather than leaving the perimeter to be guessed from a green. What now runs in the fast suite: the order of preference including the two arms appended last, the nth-match wrapping and its one-based index, the null for an element the document does not hold, the quoting of a value containing a quote or a backslash, and the refusal of a placeholder href. Seven known-bad inputs, seven killed. THE WIKI SAMPLES. writing-an-mcp-client-in-python.md publishes a transcript of driving this server, and its browser_list line carried running: false - removed in 0.54.0 - inside a fleet holding a browser that a freshly started server has not had since open-first landed in 0.53.0. Three versions stale, on the page somebody follows to write their first client. Nothing was watching. The content gate anchors on the total character count of the tool descriptions, which moves whenever a tool is added, removed or reworded, and it did its job the day browser_list was rewritten. A JSON sample is prose to it. So the new gate asks the server what the answer looks like rather than holding a list of field names that would go stale the same way the samples did, and compares the VOCABULARY at both levels: a sample may show a different session than yours, and may not show a field that does not exist. A second assertion covers the half the keys cannot see - the old sample's fields were all real in their day, and what made it wrong was that it showed a browser beside words saying nothing was open. Two known-bad inputs, two killed. And one comment that had not been true for fifty versions: the constant the version gate uses as its live known-bad called itself "the release BEFORE the current line of work" while pointing at v0.5.0. The assertion was never wrong - the package has certainly moved since - but the sentence described something somebody would have to update every release, and nobody did. It now says what that value actually has to be, which is old and present in the clone. | 7 天前 | |
The half the gate said it could not do, and one declaration of asyncio (#1352) 0.67.0 shipped a gate on dead product surface and declared methods out of scope, with a reason: a method reaches the code as an attribute, and plan.describe and SessionPlan.describe are the same attribute name, so counting names cannot tell the dead one from the live one beside it. That reason was true and it was not a limit, it was a missing step. It is done now. RESOLVE THE OWNER, AND THE AMBIGUITY GOES AWAY. An attribute on a name that the file imported as a MODULE is the module's function and never the method; self.X, Class.X and anything.X could all be the method and count. The second half is deliberately generous - an arbitrary expression counts as a reference to every method of that name - so it errs by letting something live, never by accusing it. 47 methods judged out of the 59 defined: the twelve left out are methods of a class WITH A BASE, which may be satisfying somebody else's contract, and decorated ones, which can be handed somewhere this cannot follow. The real-tree mutation it exists for - SessionPlan.describe put back - is named by file and line. AND THE FIRST VERSION OF IT LET THAT MUTATION SURVIVE. Strings were split into words, so the docstrings in plan.py - which say "describe() reads the KWARGS" - counted as reaching the method. The check was satisfied by the prose beside the code, which is the most repeated defect in this project, met inside the tool built to find it. Whole-string identifiers only now, which keeps the genuine getattr case and drops the sentences: the universe of attribute names went from 4,798 to 500, so nine tenths of what was keeping methods alive was prose. Floors AND a ceiling are asserted, because a collapse makes every method look dead, an explosion makes every one look alive, and a hand-written perimeter goes stale the day somebody changes what is excluded - which it already had, at 59. THE PAGE WAS SCANNED THE SAME WAY AND IS CLEAN, which is worth writing down as a measured negative rather than an assumption: src/aihawk/ui is product surface too and 0.67.0 never looked at it. 109 top-level JS bindings, every one named by another file or by the markup; 49 CSS classes, every one applied, confirmed by a second measurement taken a different way. The ten scripts become one concatenated script, so the question is the same one the Python gate asks. No gate here - the page has its own in test_the_browser_workspace.py - and the numbers are in the module docstring. ASYNCIO WAS DECLARED 46 TIMES FOR ONE FACT. pyproject.toml has asyncio_mode = "auto", which marks every async test already; 45 more copies sat in the files as @pytest.mark.asyncio or a module-level pytestmark. Copies of one fact can disagree with it and these did: nine sat on SYNCHRONOUS functions, so every run printed nine warnings saying the marker did not belong. Noise is where a real warning hides. They are gone and the suite prints none. What makes that safe is a gate rather than a hope. Under strict mode pytest-asyncio SKIPS an unmarked async test instead of failing it, so deleting the surviving declaration would leave the suite green while 45 tests quietly stopped running. test_the_suite_declares_asyncio_once.py holds the declaration, proves its own known-bad against a mutated copy of the file, and refuses a file that starts declaring it again - because one re-added marker is harmless on its own, which is exactly how the other 44 would follow. AND THAT LAST CHECK EARNED ITSELF IMMEDIATELY: it found two the removal script had missed, both in list form (pytestmark = [pytest.mark.asyncio, ...]) where the pattern did not match. A partial job that reports success is the thing a gate is for. A must-not-fire case failed on the first run for the second time in two days, and the gate was right both times: a toy module whose outermost function has no caller HAS a dead surface. The real package always has an outer caller; a fixture does not unless it is given one. Suite 664 green from 654, with no warnings. Tests only, nothing under src, so no version: the gate says in its own words that tests alter nothing anybody installs. | 6 天前 | |
The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) (#1253) * Squashed 'incoming-mcp/' content from commit f0cb73e git-subtree-dir: incoming-mcp git-subtree-split: f0cb73eef0241e4fe755447491d8839089faec8e * The MCP server moves in: aihawk.mcp, one package, one release (0.10.0) The server lived in its own repository, invisible-playwright-mcp, with four stars against thirty thousand here, and every change to it landed twice: a floor to raise here, a verb table to keep in step, a README that was already a pointer at this one. It now ships inside this package as the module aihawk.mcp, started by the invisible-playwright-mcp command or by python -m aihawk.mcp, which is what the interface spawns. The module is renamed on purpose: a wheel of aihawk owning the old module's files would have them deleted by pip when the old package is uninstalled during an upgrade. The PyPI name invisible-playwright-mcp lives on as a shim, a compatibility module and the same console script over aihawk, so every client registered with uvx invisible-playwright-mcp keeps working; the shim is published from the old repository right after this release, and that repository is then archived, never deleted. What moved: the ten modules under src/aihawk/mcp, twenty-six test files under tests/mcp_server (the two that guarded the standalone package's own version and release pages are gone, aihawk's cover the wheel), the server's README as the wiki page mcp-server and its tool-design notes as mcp-tool-design, with the nineteen wiki links that pointed at the old repository retargeted. The dependency on the server becomes the server's own dependencies (mcp capped below 2, invisible-playwright with its floor, selectolax). The publish gate checks the engine floor instead of the server floor, the wheel checks prove the module and the second console script, and a new e2e job runs the server's browser tests on one platform the way its own CI did. aihawk's __version__ was a stale literal and now reads the installed distribution. Measured before this commit, with the built wheels: default suite 333 passed, the e2e subset 21 passed against a cached engine, the clean-venv install answers on both commands, pip upgrade paths from 0.15.2 and from aihawk 0.9.2 end with the old import reaching aihawk.mcp, and uvx with an empty cache resolves the shim to this wheel and drives a page. | 14 天前 | |
Open first: browser_open is the only tool that opens a browser (0.53.0) (#1333) The lifecycle had seven steps - declare, wake, look, act, rebuild, remember, close - because a command aimed at a browser that was not running STARTED one, and an action whose browser had died REBUILT it and ran the action again. Each was a place to be wrong, and both were. The owner cut it on 2026-09-14. What is left: - `browser_open` opens a browser and nothing else does. With no arguments `main` is the person this session already was: seed, exit and profile read from the file, which `browser_open` writes and which nothing else touches. Any argument is a decision and goes through the planner. - Every other tool goes through one funnel, `Work.acting`. A browser that is not open answers `the main browser is not open. Call browser_open to open it.`; one that was open and died answers `the main browser is gone: it closed or crashed. Call browser_open to open it again; it comes back as the same person.` and is forgotten, so the next open starts clean. A closed target raised under an action is the same fact and the same sentence. A page that refuses passes through untouched. - `browser_close` frees the engine and keeps who it was. - The live pane reads the not-open sentence as idle and every other sentence as something to say. What died with it: `registry.py` (367 lines - locks per key, refusals, tab numbering across rebuilds, ensure/drop/declare/on_change/is_dead), half of `work.py` (ready, looking, already_open, retrying, the pages owed and seen, three sentences for "nothing running"), the tab bookkeeping in `session.py` (ids, counter, active flag, close_page: a browser drives one page, the newest live one of its context), and the strip of other browsers in the page with its stylesheet, empty by construction once nothing is "declared and not started". Nine test files of the old lifecycle are one file of the new one. The model's instructions open with it, and so does the system prompt. Driven with the real model from this tree: asked to read a heading with nothing open, it called browser_open first, then navigated. The page loads with no script error and one screen. Eight mutations, seven killed and one equivalent (remember called for the helper writes nothing either way). Suite 584; engine tests 32 green on firefox-30. | 7 天前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 9 天前 | ||
| 3 天前 | ||
| 6 天前 | ||
| 7 天前 | ||
| 6 天前 | ||
| 7 天前 | ||
| 14 天前 | ||
| 6 天前 | ||
| 6 天前 | ||
| 9 天前 | ||
| 7 天前 | ||
| 7 天前 | ||
| 6 天前 | ||
| 14 天前 | ||
| 6 天前 | ||
| 14 天前 | ||
| 6 天前 | ||
| 6 天前 | ||
| 6 天前 | ||
| 14 天前 | ||
| 14 天前 | ||
| 9 天前 | ||
| 6 天前 | ||
| 5 天前 | ||
| 6 天前 | ||
| 7 天前 | ||
| 6 天前 | ||
| 14 天前 | ||
| 14 天前 | ||
| 6 天前 | ||
| 7 天前 | ||
| 7 天前 | ||
| 6 天前 | ||
| 14 天前 | ||
| 7 天前 |