Konubinix' opinionated web of thoughts

Remote Control Qutebrowser

Fleeting

tl;dr
AI agents get seeing and interacting on my qutebrowser as separate tools, which my harness grants apart.
authorship
this note is edited using Claude/default, governed by the note how to do literate programming

Giving AI agents partial access to my running qutebrowser

why
so those agents can help me investigate issues.
what
partial means I grant seeing and interacting apart.
rule
a solution that leaves how qutebrowser is launched unchanged is preferred.
  • scope: opening its DevTools port, which needs such a change, is the fallback.
how
the agent’s tools come from a small MCP server that sends commands to the socket a running qutebrowser listens on1, with seeing and interacting in separate tools.

Stories

Getting help on a payment page, then on an AWS page

rule
this story asserts that the agent sees the page, that it interacts with it through separate tools, that I follow what it does, and that it finds the page settled.
On a payment page
what
before I start qutebrowser, an agent asking to see is told qutebrowser is not running.

early = await share()
missing = await early.call("readonly_read_dom")
assert missing.isError and "qutebrowser is not running" in missing.content[0].text, (
    f"the agent was not told qutebrowser is not running: {missing.content[0].text}"
)
what
I am on a payment page.

open_page("payment.html")
what
I share it with an agent, letting it see only; it first takes the tab I show as its own.

agent = await share()
claimed = await agent.call("claim_shown_tab")
assert not claimed.isError, f"the agent could not take the tab I show: {claimed.content[0].text}"
what
the agent explains a line of the page.

dom = (await agent.call("readonly_read_dom")).content[0].text
assert "Total: 42.00 €" in dom, f"the payment page the agent reads lacks its total: {dom}"
what
unasked, the agent tries to click « Pay », and my harness refuses it.

with pytest.raises(Refused):
    await agent.call("click", {"selector": "button", "why": "pay"})
assert not pages.paid.is_set(), "« Pay » was clicked"
Then on an AWS page
what
I land on an AWS page, trying to configure something boring.
  • example: an AgentCore Bedrock MCP.
  • how: on that page, the test keeps the clock the agent’s page script goes by, as I could by :jseval, so that seconds I would wait out are moved on at once.

open_page("aws.html")
keep_clock(runtime)
what
the agent takes the tab I now show as its own.

claimed = await agent.call("claim_shown_tab")
assert not claimed.isError, f"the agent could not take the AWS tab: {claimed.content[0].text}"
what
I let the agent interact too.

agent.grant_all()
what
the agent tells me what the field I name currently holds.

field = (await agent.call("readonly_read_dom", {"selector": "[name=region]"})).content[0].text
assert "Save" not in field, f"the agent read more than the field it named: {field}"
region = re.search(r'value="([^"]*)"', field)
assert region and region[1] == "us-east-1", f"the region the agent reads is not us-east-1: {field}"
what
the agent helps me grasp the meaning of the buttons, forms and obscure concepts on that page.
what
it takes a screenshot of the page, telling me why.

blind = await agent.call("readonly_screenshot", {"why": " "})
assert blind.isError, "the agent took a screenshot without saying why"
shot = (await agent.call("readonly_screenshot", {"why": "see the page's orange band"})).content[0]
image = PIL.Image.open(io.BytesIO(base64.b64decode(shot.data))).convert("RGB")
assert AWS_ORANGE in image.getdata(), "the screenshot the agent sees lacks the page's orange band"
what
once I grasp the field, the agent fills it, telling me why it does not ask me first.

blind = await agent.call("fill", {"selector": "[name=region]", "text": "eu-west-3", "why": " "})
assert blind.isError, "the agent filled unasked without saying why"
_, seen = await watch(agent, "fill", {"selector": "[name=region]", "text": "eu-west-3", "why": "you said fill it unasked"})
what
I followed it: the field stood out in cyan, the colour of a fill, as it was filled, though the page draws no outline.

assert any(FILL_CYAN in shot.getdata() for _, shot in seen), "the field did not stand out in the fill's colour"
what
it stood out at least 1.1 seconds before eu-west-3 showed in it.
  • how: of the two seconds it stands out first, my looks, a screenshot each and at most 0.4 seconds apart, may miss two looks’ worth, so a lead of one second stays below.

assert lead(seen, FILL_CYAN) >= STAND_OUT_LEAD, "the field did not stand out long enough before it was filled"
what
once filled, the field still stands out.

assert FILL_CYAN in (await screen(agent)).getdata(), "the field no longer stood out once filled"
what
the form wants my logo too, and the agent uploads it from my disk through « Upload », and is told it did.

logo = tmp_path / "logo.png"
logo.write_bytes(b"\x89PNG\r\n\x1a\n")
uploaded, seen = await watch(agent, "upload", {"selector": "#upload-logo", "path": str(logo), "why": "you said upload it unasked"})
done = landed_at(seen, UPLOAD_YELLOW)
assert not uploaded.isError, f"the agent was told the upload failed: {uploaded.content[0].text}"
dom = (await agent.call("readonly_read_dom")).content[0].text
chosen = re.search(r'<output id="logo">([^<]*)</output>', dom)
assert chosen and chosen[1] == "logo.png", f"the AWS page got {chosen and chosen[1]!r} as logo, not my logo.png"
what
« Upload » stood out at least 1.1 seconds before the logo’s name showed.

assert lead(seen, UPLOAD_YELLOW) >= STAND_OUT_LEAD, "« Upload » did not stand out long enough before the upload"
what
once uploaded, « Upload » still stands out.

assert UPLOAD_YELLOW in (await screen(agent)).getdata(), "« Upload » no longer stood out once uploaded"
what
with this next action, the region field no longer stands out.

assert FILL_CYAN not in (await screen(agent)).getdata(), "the region field still stood out after the upload"
what
I look away; 8 seconds after the upload, « Upload » still stands out.
  • how: its frame stays ten seconds, and 8 and 12 sit two seconds either side of them.

assert UPLOAD_YELLOW in (await screen_at(agent, runtime, done, STILL_SEEN)).getdata(), "« Upload » no longer stood out 8 seconds on"
what
12 seconds after, it no longer does.

assert UPLOAD_YELLOW not in (await screen_at(agent, runtime, done, GONE_BY)).getdata(), "« Upload » still stood out 12 seconds on"
what
it wants a product logo as well, whose input lies invisible over its « Choose file » button, and the agent uploads it there too.

uploaded = await agent.call("upload", {"selector": '[aria-label="Choose file"]', "path": str(logo), "why": "you said upload it unasked"})
assert not uploaded.isError, f"the agent was told the upload failed: {uploaded.content[0].text}"
dom = (await agent.call("readonly_read_dom")).content[0].text
chosen = re.search(r'<output id="product-logo">([^<]*)</output>', dom)
assert chosen and chosen[1] == "logo.png", f"the AWS page got {chosen and chosen[1]!r} as product logo, not my logo.png"
what
it opens the list of categories, which opens as the mouse is pressed on it.

await agent.call("click", {"selector": "#categories", "why": "opening a list changes nothing"})
categories = (await agent.call("readonly_read_dom", {"selector": "#categories"})).content[0].text
assert 'aria-expanded="true"' in categories, f"the list of categories stayed shut: {categories}"
what
with this next action, « Choose file » no longer stands out.

assert UPLOAD_YELLOW not in (await screen(agent)).getdata(), "« Choose file » still stood out once the categories opened"
what
working in my terminal meanwhile, I let the agent open the list of owners, which opens on a press of the mouse’s left button alone, as a Radix select does.

terminal = subprocess.Popen(["xterm", "-title", "my-work"])
try:
    mine = subprocess.run(["xdotool", "search", "--sync", "--name", "^my-work$"],
                          capture_output=True, text=True, check=True).stdout.split()[0]
    subprocess.run(["xdotool", "windowactivate", "--sync", mine], check=True)
    assert focused_window() == mine, "I could not get into my terminal"
    await agent.call("click", {"selector": "#owner", "why": "opening a list changes nothing"})
    owner = (await agent.call("readonly_read_dom", {"selector": "#owner"})).content[0].text
    assert 'aria-expanded="true"' in owner, f"the list of owners stayed shut while I worked elsewhere: {owner}"
finally:
    terminal.terminate()
what
it then picks the owner Keyban in that list.

await agent.call("click", {"selector": '[data-value="keyban"]', "why": "you said pick keyban unasked"})
picked = (await agent.call("readonly_read_dom", {"selector": "#owner-picked", "text": True})).content[0].text
assert picked == "Keyban", f"the owner the page took is {picked!r}, not Keyban"
what
the agent closes the notice the page shows; once the notice is gone, nothing stands out where it was.

await agent.call("click", {"selector": "#close-notice", "why": "closing a notice changes nothing"})
assert HIGHLIGHT_PURPLE not in (await screen(agent)).getdata(), "« Close » still stood out once the notice was gone"
what
it reads me the names of the categories, and only them, however heavy the markup around.

names = (await agent.call("readonly_read_dom", {"selector": "#category-list [role=option]", "text": True})).content[0].text
assert names == "AI Agents & Tools\nFinance & Accounting", f"the agent read more, or less, than the names: {names}"
what
it reads me the headers I see, and none of those the page keeps hidden.

headers = (await agent.call("readonly_read_dom", {"selector": "h2", "text": True, "shown": True})).content[0].text
assert headers == "Region settings", f"the agent read other headers than those I see: {headers}"
what
it opens the list of tiers through « Choose a tier ».

await agent.call("click", {"selector": "#tier", "why": "opening a list changes nothing"})
what
it picks the zone « b » in the page’s plain list.

selected, seen = await watch(agent, "select", {"selector": "#zone", "option": "b", "why": "you said pick it unasked"})
done = landed_at(seen, SELECT_GREEN)
assert not selected.isError, f"the agent could not pick b: {selected.content[0].text}"
shown = (await agent.call("readonly_read_dom", {"selector": "#zone-picked", "text": True})).content[0].text
assert shown == "b", f"the page took {shown!r}, not b"
what
with this next action, « Choose a tier » no longer stands out.

assert HIGHLIGHT_PURPLE not in (await screen(agent)).getdata(), "« Choose a tier » still stood out once the zone was picked"
what
the zone stood out at least 1.1 seconds before « b » showed as picked.

assert lead(seen, SELECT_GREEN) >= STAND_OUT_LEAD, "the zone did not stand out long enough before its pick"
what
once picked, the zone still stands out.

assert SELECT_GREEN in (await screen(agent)).getdata(), "the zone no longer stood out once picked"
what
I look away; 8 seconds after the pick, the zone still stands out.

assert SELECT_GREEN in (await screen_at(agent, runtime, done, STILL_SEEN)).getdata(), "the zone no longer stood out 8 seconds on"
what
12 seconds after, it no longer does.

assert SELECT_GREEN not in (await screen_at(agent, runtime, done, GONE_BY)).getdata(), "the zone still stood out 12 seconds on"
what
then it picks the tier « premium » in a list the page draws itself, where each tier’s name comes with a description, by the name I see, whatever its case.
  • what: the list of tiers was left open, and its button, pressed again, would close it.
  • what: it picks « remove a version » in the « Request changes » menu the same way, a menu’s items being alike to a selector.

watched, after = {}, {}
for list_, option, picked, name in [
    ("#tier", "premium", "#tier-picked", "Premium"),
    ("#request-changes", "remove a version", "#requested", "Remove a version"),
]:
    selected, watched[list_] = await watch(agent, "select", {"selector": list_, "option": option, "why": "you said pick them unasked"})
    after[list_] = await screen(agent)
    assert not selected.isError, f"the agent could not pick {option}: {selected.content[0].text}"
    shown = (await agent.call("readonly_read_dom", {"selector": picked, "text": True})).content[0].text
    assert shown == name, f"the page took {shown!r}, not {name}"
what
each list stood out at least 1.1 seconds before its pick showed.

for list_, seen in watched.items():
    assert lead(seen, SELECT_GREEN) >= STAND_OUT_LEAD, f"{list_} did not stand out long enough before its pick"
what
once picked, each list still stands out.

for list_, look in after.items():
    assert SELECT_GREEN in look.getdata(), f"{list_} no longer stood out once picked"
what
it tries to click the logo’s hidden input, which no mouse can reach, and is told so.

hidden = await agent.call("click", {"selector": "[name=logo]", "why": "you said click it unasked"})
assert hidden.isError and "not shown" in hidden.content[0].text, (
    f"the agent was not told the element is not shown: {hidden.content[0].text}"
)
what
the IAM choice lies in the closed « Inbound Auth » section; told what opens it, the agent opens it, then picks IAM.

collapsed = await agent.call("click", {"selector": "#iam", "why": "you said click it unasked"})
opener = '[aria-controls~="auth-body"]'
assert collapsed.isError and opener in collapsed.content[0].text, (
    f"the agent was not told what opens the section: {collapsed.content[0].text}"
)
await agent.call("click", {"selector": opener, "why": "unfolding a part changes nothing"})
opened = await agent.call("click", {"selector": "#iam", "why": "you said click it unasked"})
assert not opened.isError, f"the agent could not pick IAM once the section opened: {opened.content[0].text}"
what
with these next actions, « Request changes » no longer stands out.

assert SELECT_GREEN not in (await screen(agent)).getdata(), "« Request changes » still stood out once IAM was picked"
what
it ticks the network « BaseSepolia » in a menu of checkboxes that stays open, as Keyban’s quotas page has, the tick showing only on the item.

ticked = await agent.call("select", {"selector": "#networks", "option": "BaseSepolia", "why": "you said tick it unasked"})
assert not ticked.isError, f"the agent could not tick BaseSepolia: {ticked.content[0].text}"
item = (await agent.call("readonly_read_dom", {"selector": "#base-sepolia"})).content[0].text
assert 'aria-checked="true"' in item, f"BaseSepolia is not ticked: {item}"
what
it follows the link to my role, naming it by its address, which holds a query, as AWS’s do.

followed = await agent.call("click", {"selector": 'a[target="_blank" href="#/console?account_id=42&role_name=AdministratorAccess"]', "why": "following a link changes nothing"})
assert not followed.isError, f"the agent could not follow the link: {followed.content[0].text}"
route = (await agent.call("readonly_read_dom", {"selector": "#route", "text": True})).content[0].text
assert route == "#/console?account_id=42&role_name=AdministratorAccess", f"the link led elsewhere: {route!r}"
what
it names « Save » the way Playwright would, which is no CSS, and is told so at once.

wrong = await agent.call("click", {"selector": 'button:has-text("Save")', "why": "you said save unasked"})
assert wrong.isError and "not a valid selector" in wrong.content[0].text, (
    f"the agent was not told its selector is wrong: {wrong.content[0].text}"
)
what
then it clicks « Save ».

_, seen = await watch(agent, "click", {"selector": "button", "why": "you said click it unasked"})
done = landed_at(seen, HIGHLIGHT_PURPLE)
dom = (await agent.call("readonly_read_dom")).content[0].text
saved = re.search(r'<output id="saved">([^<]*)</output>', dom)
assert saved and saved[1] == "eu-west-3", f"the AWS page saved {saved and saved[1]!r}, not what the agent filled"
what
I followed it: « Save » stood out in purple before the click landed on it.

assert any(HIGHLIGHT_PURPLE in shot.getdata() for _, shot in seen), "« Save » did not stand out as it was clicked"
what
« Save » stood out at least 1.1 seconds before eu-west-3 showed as saved.

assert lead(seen, HIGHLIGHT_PURPLE) >= STAND_OUT_LEAD, "« Save » did not stand out long enough before the click"
what
once clicked, « Save » still stands out.

assert HIGHLIGHT_PURPLE in (await screen(agent)).getdata(), "« Save » no longer stood out once clicked"
what
I look away; 8 seconds after the click, « Save » still stands out.

assert HIGHLIGHT_PURPLE in (await screen_at(agent, runtime, done, STILL_SEEN)).getdata(), "« Save » no longer stood out 8 seconds on"
what
12 seconds after, it no longer does.

assert HIGHLIGHT_PURPLE not in (await screen_at(agent, runtime, done, GONE_BY)).getdata(), "« Save » still stood out 12 seconds on"
Then accepting each action myself
what
I want to accept each fill before it happens, so my harness grants the fill that asks me, and no longer the one that does not.

agent.granted = (agent.granted - {"fill"}) | {"confirm_fill"}
with pytest.raises(Refused):
    await agent.call("fill", {"selector": "[name=region]", "text": "eu-west-1", "why": "you said fill it unasked"})
what
the agent wants to fill the region with eu-west-2, and my harness withdraws its call as the page asks me.

async with anyio.create_task_group() as withdrawn:
    withdrawn.start_soon(agent.call, "confirm_fill", {"selector": "[name=region]", "text": "eu-west-2"})
    await question_seen(agent)
    withdrawn.cancel_scope.cancel()
what
the agent wants to fill the region with eu-west-1; the page asks me beside the field, I refuse, and the agent is told.

questions = []

async def count_questions():
    while not any("eu-west-1" in question for question in questions):
        read = (await agent.call("readonly_read_dom", {"selector": "[data-qutebrowser-mcp-question]", "text": True})).content[0].text
        questions[:] = [line for line in read.splitlines() if line]

refused = await asked(agent, runtime, "confirm_fill", {"selector": "[name=region]", "text": "eu-west-1"}, "no", count_questions)
assert refused.isError and "refused" in refused.content[0].text, f"the agent was not told I refused: {refused.content[0].text}"
field = (await agent.call("readonly_read_dom", {"selector": "[name=region]"})).content[0].text
assert 'value="eu-west-3"' in field, f"the field changed though I refused: {field}"
what
when it asked me, its question was the only one: the withdrawn call’s had gone.

assert len(questions) == 1, f"the page asked me {len(questions)} questions at once: {questions}"
what
it asks again, I accept, and the field holds eu-west-1.

accepted = await asked(agent, runtime, "confirm_fill", {"selector": "[name=region]", "text": "eu-west-1"}, "yes")
done = time.monotonic()
assert not accepted.isError, f"the fill failed though I accepted: {accepted.content[0].text}"
field = (await agent.call("readonly_read_dom", {"selector": "[name=region]"})).content[0].text
assert 'value="eu-west-1"' in field, f"the field did not take what I accepted: {field}"
what
I look away; 8 seconds after, the field still stands out.

assert FILL_CYAN in (await screen_at(agent, runtime, done, STILL_SEEN)).getdata(), "the field no longer stood out 8 seconds on"
what
12 seconds after, it no longer does.

assert FILL_CYAN not in (await screen_at(agent, runtime, done, GONE_BY)).getdata(), "the field still stood out 12 seconds on"
what
the agent shows me the scroll counter, low on the page: the page scrolls to it and I see it framed in blue.

shown, screens = await watch(agent, "readonly_show", {"selector": "#scrolled"})
assert not shown.isError, f"the agent could not show me the counter: {shown.content[0].text}"
assert any(SHOW_BLUE in shot.getdata() for _, shot in screens), "I never saw the counter framed in blue"
scrolled = (await agent.call("readonly_read_dom", {"selector": "#scrolled", "text": True})).content[0].text
assert scrolled != "0", "the page did not scroll to the counter"
what
while the agent asks me, its page’s title, and so the window’s, starts with « 🤖 », telling me it acts; once done, it still does, the tab being the agent’s own.

titled = []

async def look_at_title():
    page = (await agent.session.call_tool("readonly_read_dom", {"selector": "title", "text": True})).content[0].text
    titled.append((page.startswith("🤖"), window_named("🤖")))

await asked(agent, runtime, "confirm_fill", {"selector": "[name=region]", "text": "eu-west-1"}, "yes", look_at_title)
assert titled == [(True, True)], f"the page's and window's titles did not tell me the agent was acting: {titled}"
assert window_named("🤖"), "the window's title no longer says the tab is the agent's, once it is done"
what
asked again about the region, I scroll to the bottom of the page by mistake; s brings the field back into view, and I accept.

async def scroll_away():
    tell_qutebrowser(runtime, ":scroll-to-perc 100")
    await anyio.sleep(ANSWER_POLL)

back = await asked(agent, runtime, "confirm_fill", {"selector": "[name=region]", "text": "eu-west-2"}, "sy", scroll_away)
assert not back.isError, f"the fill failed though I accepted: {back.content[0].text}"
scrolled = (await agent.call("readonly_read_dom", {"selector": "#scrolled", "text": True})).content[0].text
assert scrolled == "0", f"s did not bring the region field back into view: scrolled by {scrolled}"
what
the agent opens the « Request changes » menu, and asks me before picking « Add new version » in it; I accept, and the menu, still open, takes the pick.

await agent.call("click", {"selector": "#request-changes", "why": "you said click it unasked"})
picked = await asked(agent, runtime, "confirm_click", {"selector": "#add-version"}, "yes")
assert not picked.isError, f"the pick failed though I accepted: {picked.content[0].text}"
requested = (await agent.call("readonly_read_dom", {"selector": "#requested", "text": True})).content[0].text
assert requested == "Add new version", f"the menu did not take the pick: {requested!r}"
what
the agent has many fields to fill in the form, starting with the variables; asked, I answer t, to trust rather than accept just this once, widen the frame from the field to the form with +, and trust it with y.

trusting = await asked(agent, runtime, "confirm_fill", {"selector": "[name=variables]", "text": "REGION=eu-west-1"}, "t+y")
assert not trusting.isError, f"the fill I trusted failed: {trusting.content[0].text}"
what
the part I trusted stays framed, so I keep seeing what goes unasked.

shot = (await agent.call("readonly_screenshot", {"why": "see whether the trusted part stays framed"})).content[0]
image = PIL.Image.open(io.BytesIO(base64.b64decode(shot.data))).convert("RGB")
assert TRUSTED_TEAL in image.getdata(), "the part I trusted no longer shows as trusted"
what
its fills within the form then go unasked.

with anyio.fail_after(UNASKED_WAIT):
    unasked = await agent.call("confirm_fill", {"selector": "[name=region]", "text": "eu-south-1"})
assert not unasked.isError, f"the fill within the trusted part failed: {unasked.content[0].text}"
what
while a click outside the form still asks me, and I refuse it.

outside = await asked(agent, runtime, "confirm_click", {"selector": "#request-changes"}, "no")
assert outside.isError and "refused" in outside.content[0].text, f"a click outside went unasked: {outside.content[0].text}"
what
on second thought, I narrow the trust back to the variables alone: ✎, then i, - and y; the region’s fill asks me again, and I refuse it.

tell_qutebrowser(runtime, ":click-element --select-first css '[data-qutebrowser-mcp-trust=edit]'")
await anyio.sleep(ANSWER_POLL)
for key in "-y":
    tell_qutebrowser(runtime, f":fake-key {key}")
    await anyio.sleep(ANSWER_POLL)
await seen_trusted(agent, "textarea")
narrowed = await asked(agent, runtime, "confirm_fill", {"selector": "[name=region]", "text": "eu-north-1"}, "no")
assert narrowed.isError and "refused" in narrowed.content[0].text, f"the region went unasked: {narrowed.content[0].text}"
what
then I stretch the trust over its siblings back to the region field: ✎, then <, < , < and y; the region’s fill goes unasked, while the Upload button after it still asks me, and I refuse it.

tell_qutebrowser(runtime, ":click-element --select-first css '[data-qutebrowser-mcp-trust=edit]'")
await anyio.sleep(ANSWER_POLL)
for key in "<<<y":
    tell_qutebrowser(runtime, f":fake-key {key}")
    await anyio.sleep(ANSWER_POLL)
await seen_trusted(agent, "label")
kept = (await agent.call("readonly_read_dom", {"selector": "[data-qutebrowser-mcp-trust=edit]"})).content[0].text
assert kept, "y did not keep the trust I stretched"
with anyio.fail_after(UNASKED_WAIT):
    stretched = await agent.call("confirm_fill", {"selector": "[name=region]", "text": "eu-west-3"})
assert not stretched.isError, f"the fill in the stretched trust failed: {stretched.content[0].text}"
after = await asked(agent, runtime, "confirm_click", {"selector": "#upload-logo"}, "no")
assert after.isError and "refused" in after.content[0].text, f"the Upload button went unasked: {after.content[0].text}"
what
then I move the trust to another part: ✎, then I go to the region field; my mouse wanders over the page on its way to ✓, the trust staying on the region field; I keep it there, and the region’s fill goes unasked.

tell_qutebrowser(runtime, ":click-element --select-first css '[data-qutebrowser-mcp-trust=edit]'")
await anyio.sleep(ANSWER_POLL)
tell_qutebrowser(runtime, ":click-element --select-first css '[name=region]'")
await seen_trusted(agent, "input")
wander()
await anyio.sleep(ANSWER_POLL)
trusted = (await agent.call("readonly_read_dom", {"selector": "[data-qutebrowser-mcp-trusted]"})).content[0].text
assert trusted.startswith("<input"), f"the trust followed my mouse: {trusted}"
tell_qutebrowser(runtime, ":click-element --select-first css '[data-qutebrowser-mcp-trust=keep]'")
await anyio.sleep(ANSWER_POLL)
with anyio.fail_after(UNASKED_WAIT):
    moved = await agent.call("confirm_fill", {"selector": "[name=region]", "text": "eu-south-2"})
assert not moved.isError, f"the fill in the part I moved the trust to failed: {moved.content[0].text}"
what
then I drop the trust with its ×; the variables’ fill asks me again, and I refuse it.

tell_qutebrowser(runtime, ":click-element --select-first css '[data-qutebrowser-mcp-trust=drop]'")
await seen_trusted(agent, "")
dropped = await asked(agent, runtime, "confirm_fill", {"selector": "[name=variables]", "text": "REGION=eu-north-1"}, "no")
assert dropped.isError and "refused" in dropped.content[0].text, f"the variables went unasked: {dropped.content[0].text}"
Then elsewhere
what
the agent takes me to the documentation, in the tab I see, through a link with a query and an anchor, as AWS’s links have.

site = f"http://127.0.0.1:{pages.server_port}"
opened = (await agent.call("open_url", {"url": f"{site}/docs.html?from=console&account=42#runtime"})).content[0].text
title = (await agent.call("readonly_read_dom", {"selector": "h1", "text": True})).content[0].text
assert title == "AgentCore documentation", f"the agent did not take me to the documentation: {title}"
what
the page fetches its « Send feedback » button; once the agent opened it, it reads the button straight away.

button = (await agent.call("readonly_read_dom", {"selector": "#feedback", "text": True})).content[0].text
assert button == "Send feedback", f"the agent read no button right after opening the page: {button!r}"
what
it was told the page had settled, though one of its images never comes.

assert "not settled" not in opened, f"the agent was told the page never settled: {opened}"
what
it follows a link that passes it on to the documentation, as a sign-in does, and waits there for the button, across the move.

await agent.call("open_url", {"url": f"{site}/sign-in.html"})
waited = await agent.call("readonly_wait_for", {"selector": "#feedback"})
assert not waited.isError, f"the agent lost the button across the move: {waited.content[0].text}"
what
then to the payment page, in a new tab, leaving the documentation in the tab before; I go back to the documentation, and the agent still reads its payment tab, whose title starts with « 🤖 »; working in another program meanwhile, I keep its window while the agent reads.

await agent.call("open_url_in_new_tab", {"url": f"{site}/payment.html"})
total = (await agent.call("readonly_read_dom", {"selector": "#total", "text": True})).content[0].text
assert total == "Total: 42.00 €", f"the new tab does not show the payment page: {total}"
tell_qutebrowser(runtime, ":tab-prev")
total = (await agent.call("readonly_read_dom", {"selector": "#total", "text": True})).content[0].text
assert total == "Total: 42.00 €", f"the agent left its tab for mine: {total}"
title = (await agent.call("readonly_read_dom", {"selector": "title", "text": True})).content[0].text
assert title.startswith("🤖"), f"the agent's tab does not tell me it is the agent's: {title}"
terminal = subprocess.Popen(["xterm", "-title", "my-work"])
try:
    mine = subprocess.run(["xdotool", "search", "--sync", "--name", "^my-work$"],
                          capture_output=True, text=True, check=True).stdout.split()[0]
    subprocess.run(["xdotool", "mousemove", "--window", mine, "10", "10"], check=True)
    subprocess.run(["xdotool", "windowactivate", "--sync", mine], check=True)
    await anyio.sleep(ANSWER_POLL)
    assert focused_window() == mine, "I could not get into my terminal"
    await agent.call("readonly_read_dom", {"selector": "#total", "text": True})
    assert focused_window() == mine, "the agent's call took me out of the window I was in"
finally:
    terminal.terminate()
what
on a page the server changes at each visit, the agent reloads, and reads the page anew.

await agent.call("open_url", {"url": f"{site}/visits.html"})
before = (await agent.call("readonly_read_dom", {"selector": "h1", "text": True})).content[0].text
reloaded = await agent.call("reload", {"bypass_cache": True})
assert not reloaded.isError, f"the reload failed: {reloaded.content[0].text}"
after = (await agent.call("readonly_read_dom", {"selector": "h1", "text": True})).content[0].text
assert after != before, f"the page read after the reload is the one before: {after}"
what
the agent opens the documentation in yet another tab of its own; going back to its former tab, I see it no longer bears the « 🤖 », the new one alone bearing it.

opened = await agent.call("open_url_in_new_tab", {"url": f"{site}/docs.html"})
assert not opened.isError, f"the agent could not open the documentation in a new tab: {opened.content[0].text}"
title = (await agent.call("readonly_read_dom", {"selector": "title", "text": True})).content[0].text
assert title.startswith("🤖"), f"the agent's new tab does not tell me it is the agent's: {title}"
tell_qutebrowser(runtime, ":tab-prev")
former = await shown_title(runtime, tmp_path)
assert not former.startswith("🤖"), f"the agent's former tab still tells me it is the agent's: {former}"
what
I launch the software myself, as from AWS Marketplace, and its configuration opens in a tab of mine.

tell_qutebrowser(runtime, f":open --tab {site}/configure")
what
back on the AWS page, the agent launches it too; its configuration opens in a new tab, right after the agent’s, bearing the same title as mine; the agent takes the tab after its own, and reads the second configuration.

await agent.call("open_url", {"url": f"{site}/aws.html"})
await agent.call("click", {"selector": "#launch", "why": "you said click it unasked"})
tabs = json.loads((await agent.call("readonly_list_tabs")).content[0].text)
agents = next(index for index, tab in enumerate(tabs) if tab["agent's"])
after = tabs[agents + 1]["name"]
assert after.startswith("Configure software ") and after.endswith("/2"), f"the tabs read: {tabs}"
followed = await agent.call("use_tab", {"name": after})
assert not followed.isError, f"the agent could not take {after}: {followed.content[0].text}"
heading = (await agent.call("readonly_read_dom", {"selector": "h1", "text": True})).content[0].text
assert heading == "Configuration 2", f"the agent did not follow the configuration it opened: {heading!r}"

How the stories are tested

rule
a test runs each story, calling the tools the agent would use rather than driving a real agent.
  • scope: it reaches those tools, not the wiring of a real agent to them, which stays unmeasured.
  • how: it plays my harness too: it calls only the tools granted so far, and refuses a call to any other.
    • how: at first it grants only the tools the server marks read-only.
rule
the story’s test runs against pages served for it, rather than a real payment page or AWS page.
  • why: an AWS page needs my account and changes under the test, where a served page gives the same run each time.

  • example: a sign-in page, which passes me on to the documentation half a second after it loaded.

    <!doctype html>
    <title>Signing in</title>
    <script>setTimeout(() => location.replace('/docs.html'), 500)</script>
    
  • example: a documentation page, titled « AgentCore documentation ».

    • what: like AWS’s pages, it never finishes loading: one of its images never comes.
    • what: like them too, it fetches a button of its own once loaded, its server giving it three seconds late.
    <!doctype html>
    <title>Documentation</title>
    <h1>AgentCore documentation</h1>
    <img src="/never" alt="">
    <script>
      fetch('/feedback').then(answer => answer.text())
        .then(label => document.body.insertAdjacentHTML('beforeend', `<button id="feedback">${label}</button>`));
    </script>
    
  • example: a payment page whose total reads « Total: 42.00 € », with a « Pay » button.

    <!doctype html>
    <meta charset="utf-8">
    <title>Payment page</title>
    <body onload="document.querySelector('#total').textContent = 'Total: 42.00 €'; fetch('/loaded')">
      <p id="total"></p>
      <button onclick="fetch('/paid')">Pay</button>
    </body>
    
  • example: a form whose field holds « us-east-1 », with a « Save » button, under an orange band, which writes what it saves into the page.

    • what: like the fields a framework drives, it holds that value live, not in its HTML.
    • what: like AWS Marketplace’s, its « Launch software » opens the software’s configuration in a new tab.
    • what: it also takes a logo, writing the name of the file chosen.
      • what: like AWS, it hides the file input, and its « Upload » button clicks it.
    • what: it takes a product logo too, whose input lies invisible over a « Choose file » button doing nothing, as in the forms AWS builds with Cloudscape.
    • what: its list of categories opens as the mouse is pressed, not on a click, as Cloudscape’s do.
    • what: its list of owners opens on a press of the mouse’s left button alone, as Radix’s selects do.
      • what: as there, an owner is picked only once the mouse went over it before its release.
    • what: it shows a notice, which its « Close » button hides.
      • what: each category’s name lies deep in markup, as there.
    • what: like AWS’s, it keeps dialogs I do not see in its page, hidden, headers and all.
    • what: it links to my role at an address holding a query, and writes where the link led.
    • what: it has a text area of variables that, like the editors pages draw, keeps its clicks to itself.
    • what: it has a plain list of zones, and a list of tiers it draws itself, each writing what was picked.
    • what: its « Request changes » menu closes, as AWS’s do, when the page loses the focus or a press lands elsewhere.
    • what: like most forms built with a framework, it enables « Save » only once the field hears an input.
    • what: like AWS, its stylesheet draws no outline on anything, whatever the element’s own style says.
    <!doctype html>
    <title>AWS page</title>
    <style>* { outline: none !important }</style>
    <body onload="document.querySelector('#band').style.background = '#ff9900';
                  document.querySelector('[name=region]').value = 'us-east-1'; fetch('/loaded')">
      <div id="band" style="height: 50px"></div>
      <h2>Region settings</h2>
      <div role="dialog" style="display: none"><h2>Session expired</h2><section><h2>Cookie preferences</h2></section></div>
      <form onsubmit="document.querySelector('#saved').textContent = this.region.value;
                      return false">
        <label>Region <input name="region" oninput="this.form.querySelector('button').disabled = false"></label>
        <button disabled>Save</button>
        <output id="saved"></output>
        <textarea name="variables"></textarea>
        <script>
          for (const type of ['mousedown', 'mouseup', 'click'])
            addEventListener(type, event => { if (event.target.name == 'variables') event.stopImmediatePropagation() }, true);
        </script>
        <input type="file" name="logo" style="display: none" onchange="document.querySelector('#logo').textContent = this.files[0].name">
        <button type="button" id="upload-logo" onclick="this.form.querySelector('[name=logo]').click()">Upload</button>
        <a id="launch" href="/configure" target="_blank">Launch software</a>
        <output id="logo"></output>
        <span style="position: relative; display: inline-block">
          <button type="button" aria-hidden="true" tabindex="-1">Choose file</button>
          <input type="file" aria-label="Choose file" style="position: absolute; inset: 0; opacity: 0"
                 onchange="document.querySelector('#product-logo').textContent = this.files[0].name">
        </span>
        <output id="product-logo"></output>
      </form>
      <button id="categories" aria-expanded="false" onmousedown="this.setAttribute('aria-expanded', 'true')">Choose up to 3 categories</button>
      <button id="owner" type="button" role="combobox" aria-expanded="false"
              onpointerdown="if (event.button === 0 && event.pointerType === 'mouse') this.setAttribute('aria-expanded', 'true')">Choose an owner</button>
      <ul role="listbox" id="owners">
        <li role="option" data-value="keyban"
            onpointermove="this.dataset.highlighted = ''"
            onpointerup="if (event.pointerType === 'mouse' && 'highlighted' in this.dataset) document.querySelector('#owner-picked').textContent = this.textContent">Keyban</li>
      </ul>
      <output id="owner-picked"></output>
      <div id="notice">New console experience <button id="close-notice" type="button" onclick="this.parentNode.hidden = true">Close</button></div>
      <a href="#/console?account_id=42&amp;role_name=AdministratorAccess" data-testid="federation-link">AdministratorAccess</a>
      <output id="route"></output>
      <button id="request-changes" onclick="document.querySelector('#changes').hidden = false">Request changes</button>
      <ul id="changes" role="menu" hidden>
        <li role="menuitem" id="add-version" onclick="document.querySelector('#requested').textContent = this.textContent">Add new version</li>
        <li role="menuitem" onclick="document.querySelector('#requested').textContent = this.textContent; this.parentNode.hidden = true"><span>Remove a version</span></li>
      </ul>
      <output id="requested"></output>
      <button id="networks" type="button" aria-haspopup="menu"
              onclick="document.querySelector('#network-list').hidden = false">Allowed networks</button>
      <div id="network-list" role="menu" hidden>
        <div role="menuitemcheckbox" aria-checked="false">Ethereum</div>
        <div role="menuitemcheckbox" id="base-sepolia" aria-checked="false">BaseSepolia</div>
        <script>
          for (const item of document.querySelectorAll('#network-list [role=menuitemcheckbox]'))
            item.onclick = () => item.setAttribute('aria-checked', item.getAttribute('aria-checked') != 'true');
        </script>
      </div>
      <select id="zone" onchange="document.querySelector('#zone-picked').textContent = this.selectedOptions[0].text">
        <option>a</option><option>b</option>
      </select>
      <output id="zone-picked"></output>
      <button id="tier" aria-expanded="false"
              onclick="const tiers = document.querySelector('#tiers'); tiers.hidden = !tiers.hidden; this.setAttribute('aria-expanded', !tiers.hidden)">Choose a tier</button>
      <ul role="listbox" id="tiers" hidden>
        <li role="option" onclick="pickTier(this)"><span>Basic</span> <small>Mail support</small></li>
        <li role="option" onclick="pickTier(this)"><span>Premium</span> <small>Phone support, day and night</small></li>
      </ul>
      <output id="tier-picked"></output>
      <script>
        function pickTier(option) {
          const name = option.querySelector('span').textContent;
          document.querySelector('#tier-picked').textContent = name;
          document.querySelector('#tier').textContent = name;
          document.querySelector('#tier').setAttribute('aria-expanded', 'false');
          option.parentNode.hidden = true;
        }
      </script>
      <script>
        const changes = document.querySelector('#changes');
        addEventListener('blur', () => changes.hidden = true);
        document.addEventListener('mousedown', event => {
          if (!changes.contains(event.target) && event.target.id != 'request-changes') changes.hidden = true });
      </script>
      <script>onhashchange = () => document.querySelector('#route').textContent = location.hash</script>
      <ul role="listbox" id="category-list">
        <li role="option" aria-selected="false" data-group-index="1"><span class="option-label"><span>AI Agents &amp; Tools</span></span></li>
        <li role="option" aria-selected="false" data-group-index="1"><span class="option-label"><span>Finance &amp; Accounting</span></span></li>
      </ul>
      <span role="button" aria-expanded="false" aria-controls="auth-body"
            onclick="const body = document.querySelector('#auth-body'); body.hidden = !body.hidden; this.setAttribute('aria-expanded', !body.hidden)">Inbound Auth</span>
      <div id="auth-body" hidden><label><input type="radio" name="auth" id="iam"> Use IAM permissions</label></div>
      <output id="scrolled">0</output>
      <div style="height: 300vh"></div>
      <script>onscroll = () => document.querySelector('#scrolled').textContent = Math.round(scrollY)</script>
    </body>
    
rule
a dagger function runs the tests in a docker container whose image dagger builds, with qutebrowser under Xvfb.
rule
the tests are written in Python, run by pytest, and reach the tools through the MCP Python SDK.
rule
the qutebrowser a test starts has a fresh base directory, holding none of my config.
how
when the page asks me, the test answers as I would, having qutebrowser press y or n, once it sees the question on the screen.
  • scope: qutebrowser presses the key straight into the page, so the test skips the insert mode my keys need.

Promises

The agent sees the page I have open

what
as I see it, and its DOM.
therefore
the story passing entails the tools let the agent see the page, since through them the test reads what the payment page’s total and the AWS field currently hold.
therefore
it also entails the tools let the agent see the page as I see it, since through them the test finds the page’s orange band among the pixels of a screenshot.

The agent interacts with that page

what
using tools separate from those that let it see it.
  • why: so my agent harness can grant the one and refuse the other.
rule
granting and refusing is the harness’s work, not these tools'.
  • why: the harness is what provides security, and I do not reinvent it.
therefore
the story passing entails the server marks no click tool read-only, since the harness, granting only the tools so marked, refuses the click and « Pay » stays unclicked.
scope
a tool marked read-only that still acts on the page would pass unseen.
therefore
it also entails the tools let the agent interact with the page, since through them the test fills the region and clicks « Save », and the page shows it saved what was filled.
therefore
it also entails the agent follows the page its click opened in a new tab, even beside one of mine bearing the same title, since it reads there the configuration its own launch opened.

I follow what the agent does on the page

what
what the agent acts on stands out two seconds before it acts, then ten seconds after, unless its next action comes sooner.
  • why: so I follow it.
therefore
the story passing entails each fill, click, select and upload stands out before it lands, since the test sees its colour at least 1.1 seconds before the page shows the action done.
therefore
it also entails what was acted on still stands out once done, since the test sees each one’s colour right after it.
therefore
it also entails the next action takes it away, since the field no longer stands out after the upload, nor « Choose file » once the categories open, « Choose a tier » once the zone is picked, or « Request changes » once the auth section opens.
therefore
it also entails it goes by itself after ten seconds, since a fill, an upload, a pick and a click each still stand out 8 seconds after, and no longer 12 seconds after, nothing acting meanwhile.

The agent finds the page settled

what
each click, fill, select, upload and open returns once the page settled: loaded, none of its requests pending, nothing changed for half a second.
  • why: so the agent reads the page as I see it, done, not half drawn.
therefore
the story passing entails an open returns once the page settled, since right after opening the documentation the test reads the button the page fetches three seconds late.
therefore
it also entails a never-ending image keeps no page from settling, since that open is told the page settled.
scope
images and fonts go unwaited for, and an embedded frame’s requests read as pending till the five seconds pass.

The server

what
that server is a module, mcp_qutebrowser, which my own MCP server mounts beside its other themes.
how
it finds that socket where qutebrowser puts it: under my runtime directory, named after my user2.
  • how: when nothing listens there, every tool says qutebrowser is not running.

def runtime():
    return pathlib.Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "qutebrowser"


AGENT_TAB = {}


OPEN_LINGER = 0.5  # seconds


def send(command, linger=0, anywhere=False, answer=None):
    if AGENT_TAB and not anywhere:
        told = f"--answer {answer} " if answer else ""
        command = f":agent-run {told}{command.removeprefix(':')}"
    name = hashlib.md5(getpass.getuser().encode()).hexdigest()
    path = runtime() / f"ipc-{name}"
    with socket.socket(socket.AF_UNIX) as ipc:
        try:
            ipc.connect(str(path))
        except (FileNotFoundError, ConnectionRefusedError):
            raise RuntimeError(f"qutebrowser is not running: nothing listens on {path}") from None
        ipc.sendall((json.dumps({"args": [command], "target_arg": None, "protocol_version": 1}) + "\n").encode())
        time.sleep(linger)
how
a command opening a tab holds its socket open half a second before closing it.
  • why: opening the tab lets Qt handle the socket’s close while qutebrowser still reads it, and qutebrowser dies of it, as gdb showed with :tab-select.
how
once the agent opened a tab of its own, which :agent-claim makes its, every command goes through :agent-run, which runs it in that tab, made current in its window, raising no window; both commands come from my qutebrowser configuration.
  • why: qutebrowser runs a socket command in the window I last used, and its :tab-select raises the window it selects, taking my focus.
  • how: given to another window, the tab is another one to qutebrowser, so the command then looks for the tab titled « 🤖 ».
  • scope: it leans on qutebrowser’s internals, which may change between its versions.
  • how: sourcing the configuration again replaces both commands, qutebrowser refusing a command twice.
  • how: a tab I closed is let go of before anything touches it, as touching it would crash qutebrowser.

import json
import pathlib

from qutebrowser.api import cmdutils, interceptor
from qutebrowser.commands import runners
from qutebrowser.completion.models import miscmodels
from qutebrowser.misc import objects
from qutebrowser.qt import sip
from qutebrowser.qt.core import QUrl
from qutebrowser.utils import objreg, usertypes

for name in ["agent-claim", "agent-former-eval", "agent-run", "agent-eval", "agent-requests", "agent-tabs", "agent-take"]:
    objects.commands.pop(name, None)

AGENT_TAB = {}


@cmdutils.register()
@cmdutils.argument("win_id", value=cmdutils.Value.win_id)
def agent_claim(win_id: int) -> None:
    """Make the tab this window shows the agent's, keeping the former one to take its 🤖 off."""
    tabbed = objreg.get("tabbed-browser", scope="window", window=win_id)
    AGENT_TAB.update(former=AGENT_TAB.get("tab"), win_id=win_id, tab=tabbed.widget.currentWidget())


@cmdutils.register()
def agent_former_eval(script: str, answer: str) -> None:
    """Run the javascript in SCRIPT in the agent's former tab, where it is still there and not its tab now.

    Write what it gives back to ANSWER, as JSON, null when there is no such tab."""
    def answered(value):
        part = pathlib.Path(f"{answer}.part")
        part.write_text(json.dumps(value))
        part.rename(answer)

    former = AGENT_TAB.get("former")
    if former is None or sip.isdeleted(former) or former is AGENT_TAB.get("tab"):
        answered(None)
        return
    former.run_js_async(pathlib.Path(script).read_text(), answered, world=usertypes.JsWorld.jseval)


def agent_tab():
    """The window and tab of the agent: the one pointed at, which bears the 🤖."""
    if not AGENT_TAB or AGENT_TAB["win_id"] not in objreg.window_registry or sip.isdeleted(AGENT_TAB["tab"]):
        raise cmdutils.CommandError("the agent has no tab of its own: open one with open_url_in_new_tab")
    tabbed = objreg.get("tabbed-browser", scope="window", window=AGENT_TAB["win_id"])
    if tabbed.widget.indexOf(AGENT_TAB["tab"]) < 0:
        raise cmdutils.CommandError("the agent's tab is gone: open one with open_url_in_new_tab")
    return tabbed, AGENT_TAB["win_id"], AGENT_TAB["tab"]


@cmdutils.register()
@cmdutils.argument("win_id", value=cmdutils.Value.win_id)
def agent_eval(script: str, answer: str, win_id: int) -> None:
    """Run the javascript in SCRIPT in the tab this window shows, writing what it gives back to ANSWER, as JSON."""
    tab = objreg.get("tabbed-browser", scope="window", window=win_id).widget.currentWidget()

    def answered(value):
        part = pathlib.Path(f"{answer}.part")
        part.write_text(json.dumps(value))
        part.rename(answer)

    tab.run_js_async(pathlib.Path(script).read_text(), answered, world=usertypes.JsWorld.jseval)


def page_of(url):
    return url.adjusted(QUrl.UrlFormattingOption.RemoveFragment).toString(QUrl.ComponentFormattingOption.FullyEncoded)


REQUESTED = interceptor.__dict__.setdefault("agent_requested", {})  # page → what it asked for, kept across re-sourcing


AWAITED = {interceptor.ResourceType.stylesheet, interceptor.ResourceType.script, interceptor.ResourceType.xhr}


def counted(request):
    page = page_of(request.first_party_url)
    if request.resource_type == interceptor.ResourceType.main_frame:
        REQUESTED[page] = []
    elif request.resource_type in AWAITED and not request.is_blocked:
        REQUESTED.setdefault(page, []).append(request.request_url.toString(QUrl.ComponentFormattingOption.FullyEncoded))


if not interceptor.__dict__.setdefault("agent_counting", False):
    interceptor.register(lambda request: counted(request))
    interceptor.agent_counting = True


@cmdutils.register()
@cmdutils.argument("win_id", value=cmdutils.Value.win_id)
def agent_requests(answer: str, win_id: int) -> None:
    """Write to ANSWER, as JSON, what the page this window shows asked for since it began loading."""
    tab = objreg.get("tabbed-browser", scope="window", window=win_id).widget.currentWidget()
    part = pathlib.Path(f"{answer}.part")
    part.write_text(json.dumps(REQUESTED.get(page_of(tab.url()), [])))
    part.rename(answer)


@cmdutils.register()
def agent_tabs(answer: str) -> None:
    """Write to ANSWER, as JSON, every tab: as WINDOW/INDEX, its title, its address, and whether it is the agent's."""
    tabs = []
    for win_id in list(objreg.window_registry):
        tabbed = objreg.get("tabbed-browser", scope="window", window=win_id)
        for index in range(tabbed.widget.count()):
            tab = tabbed.widget.widget(index)
            tabs.append({"tab": f"{win_id}/{index + 1}", "title": tab.title(), "url": tab.url().toDisplayString(),
                         "agent's": tab is AGENT_TAB.get("tab")})
    part = pathlib.Path(f"{answer}.part")
    part.write_text(json.dumps(tabs, ensure_ascii=False))
    part.rename(answer)


@cmdutils.register()
def agent_take(tab: str) -> None:
    """Make the tab TAB, as WINDOW/INDEX, the agent's."""
    win_id, index = (int(part) for part in tab.split("/"))
    if win_id not in objreg.window_registry:
        raise cmdutils.CommandError(f"no window {win_id}")
    widget = objreg.get("tabbed-browser", scope="window", window=win_id).widget.widget(index - 1)
    if widget is None:
        raise cmdutils.CommandError(f"no tab {tab}")
    AGENT_TAB.update(win_id=win_id, tab=widget)


@cmdutils.register(maxsplit=0)
def agent_run(command: str, *, answer: str = None) -> None:
    """Run COMMAND in the agent's tab, made current in its window, raising no window.

    When the agent has no tab, write why to ANSWER, as JSON, for whoever waits on it."""
    try:
        tabbed, win_id, tab = agent_tab()
    except cmdutils.CommandError as error:
        if answer:
            part = pathlib.Path(f"{answer}.part")
            part.write_text(json.dumps({"agent_run_failed": str(error)}))
            part.rename(answer)
        raise
    tabbed.widget.setCurrentWidget(tab)
    runners.CommandRunner(win_id).run_safely(command)
how
my qutebrowser configuration sources it, and so does the test’s.
how
to see the page, it has :agent-eval, from my qutebrowser configuration, ask the page its own HTML and write it where the server reads it back, looking every tenth of a second and giving up after ten.
  • why: qutebrowser’s own copy of a page, handed to a userscript, comes back empty from a new tab still loading.
  • how: a page not yet ready for scripts answers nothing, so it is asked again till it answers.
how
given a CSS selector, it returns only the elements that match, so a big page can be read in parts.
  • how: asked for text, it returns only their text, one element to a line, so a list drowned in markup reads as its names.
  • how: asked for what is shown, the page first marks what takes no room or is made invisible, and the server leaves it out.
how
since the HTML of a field holds what it first held, not what it holds now, the page first writes each field’s live value into its HTML, marking itself as an interaction does.
  • scope: this leaves each field holding what it held, so I see no change, though the page’s scripts could.
how
it marks the tools that see as read-only, so a harness can grant them by that mark alone.
how
once the agent opened a tab of its own, each tool acts on that tab, in whatever window it went, so I wander in my tabs while it works in its own.
  • how: a tool first asks that tab’s title, each second, till qutebrowser answers, and says the tab is gone when it never does.
how
the tab the agent opened keeps a title starting with « 🤖 », even when its page retitles itself, so its tab and window show it, and no other; before it opened one, the tab it acts on shows it while a tool acts.
  • how: the title comes back once the last of the calls running at once ends, not the first.
  • how: opening a tab of its own, the agent first takes the « 🤖 » off its former one, so one tab only bears it, even one claimed before the server restarted.
  • how: the new tab bears the « 🤖 » once its page settled, past the blank page a new tab first holds, before the agent does anything there.

HAND_OVER_WAIT = 10  # seconds
HAND_OVER_POLL = 0.1  # seconds


def wait_for(path, whole=lambda data: True, wait=HAND_OVER_WAIT):
    deadline = time.monotonic() + wait
    while not (path.exists() and whole(path.read_bytes())):
        if time.monotonic() > deadline:
            raise TimeoutError(f"qutebrowser never handed {path.name} over")
        time.sleep(HAND_OVER_POLL)


def answered(path):
    """What qutebrowser wrote to PATH, or why it could not reach the agent's tab, raised at once."""
    value = json.loads(path.read_text())
    if isinstance(value, dict) and "agent_run_failed" in value:
        raise RuntimeError(value["agent_run_failed"])
    return value


def page_read(wait=HAND_OVER_WAIT):
    """The page's HTML, and the calls it ended, each with why it failed, empty when it did not."""
    deadline = time.monotonic() + wait
    with tempfile.TemporaryDirectory() as tmp:
        dom, script = pathlib.Path(tmp) / "dom.json", pathlib.Path(tmp) / "dom.js"
        script.write_text("[document.documentElement.outerHTML, (window.qutebrowserMcpState || {}).done || {}]")
        while True:
            dom.unlink(missing_ok=True)
            send(f":agent-eval {script} {dom}", answer=dom)
            wait_for(dom, wait=max(deadline - time.monotonic(), HAND_OVER_POLL))
            read = answered(dom)
            if read is not None:
                return read
            if time.monotonic() > deadline:
                raise TimeoutError("the page never let a script read it")
            time.sleep(HAND_OVER_POLL)


AGENT_MARK = "🤖"


def shown_title(wait=HAND_OVER_WAIT):
    deadline = time.monotonic() + wait
    while True:
        with tempfile.TemporaryDirectory() as tmp:
            shown, script = pathlib.Path(tmp) / "title", pathlib.Path(tmp) / "hand-over"
            script.write_text('#!/bin/sh\necho "$QUTE_TITLE" > "$1.part" && mv "$1.part" "$1"\n')
            script.chmod(0o755)
            send(f":spawn --userscript {script} {shown}")
            try:
                wait_for(shown, wait=CHECK_WAIT)
                return shown.read_text().strip()
            except TimeoutError:
                if time.monotonic() > deadline:
                    raise


ACTING = {"calls": 0, "lock": threading.Lock()}


def title_acting(acting):
    try:
        act(call("titleActing", acting), CHECK_WAIT)
    except (RuntimeError, TimeoutError, OSError):
        pass


@contextlib.contextmanager
def titled():
    with ACTING["lock"]:
        ACTING["calls"] += 1
        if ACTING["calls"] == 1:
            title_acting(True)
    try:
        yield
    finally:
        with ACTING["lock"]:
            ACTING["calls"] -= 1
            if not ACTING["calls"]:
                title_acting(False)


def acting(tool):
    @functools.wraps(tool)
    def acted(*arguments, **keywords):
        return on_agent_tab(tool, *arguments, **keywords)
    return acted


def on_agent_tab(tool, *arguments, **keywords):
    if not AGENT_TAB:
        send(":nop", anywhere=True)
        raise RuntimeError("the agent has no tab of its own: point at the tab shown with claim_shown_tab, "
                           "or open one with open_url_in_new_tab")
    began = time.monotonic()
    try:
        shown_title(CHECK_WAIT)
    except TimeoutError:
        raise RuntimeError("the agent's tab is gone: open one again with open_url_in_new_tab") from None
    checked = time.monotonic()
    result = tool(*arguments, **keywords)
    acted = time.monotonic()
    title_acting(True)
    print(f"timing {tool.__name__}: check {checked - began:.2f} s, tool {acted - checked:.2f} s,"
          f" mark {time.monotonic() - acted:.2f} s", file=sys.stderr, flush=True)
    return result


@qutebrowserserver.tool(annotations=ToolAnnotations(readOnlyHint=True))
@acting
def readonly_read_dom(selector: str = "", text: bool = False, shown: bool = False) -> str:
    """The DOM of the page qutebrowser shows, as HTML, its fields holding their live values, or only the elements SELECTOR picks.

    With TEXT, only the text of each element, one to a line, without its markup.
    With SHOWN, only what the page shows, leaving out what it keeps hidden.
    Prefer it to the screenshot: it is text."""
    page = bs4.BeautifulSoup(act(call("liveValues") + (call("markHidden") if shown else "")), "html.parser")
    if shown:
        for hidden in page.select("[data-qutebrowser-mcp-hidden]"):
            hidden.decompose()
    elements = page.select(selector) if selector else [page]
    if text:
        return "\n".join(element.get_text(" ", strip=True) for element in elements)
    return "\n".join(str(element) for element in elements)
how
to show the page as I see it, it has qutebrowser save a screenshot, and reads the image back the same way, once the file ends with the chunk that closes every PNG, since qutebrowser writes it in place.
how
the agent says why it looks, which qutebrowser shows me as it takes the shot; no why, no shot.
  • why: no code can judge a reason, so I judge it, seeing each one.

PNG_END = b"IEND\xaeB`\x82"


def telling(why, doing, rather):
    """Show me WHY the agent is DOING, refusing a blank one, RATHER saying what it skips."""
    if not why.strip():
        raise RuntimeError(f"say why you need this rather than {rather}")
    send(f":message-info {shlex.quote(f'the agent {doing}: {why}')}")


@qutebrowserserver.tool(annotations=ToolAnnotations(readOnlyHint=True))
@acting
def readonly_screenshot(why: str) -> Image:
    """What qutebrowser shows of the page, as a PNG, telling me WHY the agent looks.

    Only for what the DOM cannot say: layout, colours, images, whether something really shows.
    Otherwise use readonly_read_dom."""
    telling(why, "looks", "readonly_read_dom")
    with tempfile.TemporaryDirectory() as tmp:
        shot = pathlib.Path(tmp) / "shot.png"
        send(f":screenshot {shot}")
        wait_for(shot, lambda data: data.endswith(PNG_END))
        return Image(data=shot.read_bytes(), format="png")
what
qutebrowser’s socket answers nothing3, so the server cannot tell when a command has taken effect.
how
so to interact, it has the page run a small script, setting the value of the field a selector picks, then mark itself, and reads the page back until the mark shows, giving up once the ten seconds it grants a hand-over have gone by without it.
  • how: a click, qutebrowser makes, as a mouse would, then the page marks itself.
    • why: some menus open as the mouse is pressed, which no click by script does.
    • how: first, the page checks an element matches and is shown, since qutebrowser tells the server nothing when it cannot click.
  • scope: the mark stays in the page as an attribute.
  • how: when the script fails, the page marks itself all the same, adding why, and the agent is told that why.
  • how: the page also keeps, out of its HTML, the last fifty calls it ended, so that calls running at once, a screenshot during a fill say, each find their own end, where the mark holds only the last.
  • how: before a click, a fill, a select or an upload, the element is scrolled into view, then stands out as I follow what the agent does asks.
    • how: its frame follows it as the page scrolls.
    • how: the frame is the tool’s own, laid above the page over the element’s box, so no style of the page hides it.
  • how: a fill sets the value the way a framework expects, then tells the field it heard an input.
  • how: a select picks an option by the text I see, whatever its case: in a plain list, it sets it and tells the list; in a list the page draws, it clicks the list open, then the shown option holding that text, whole or in one of its parts, such as its label beside its description.
    • how: a menu’s items count as options, so an item is picked by its text too, its menu closing once it took.
    • how: when no such option shows, it says the options it saw, and presses Escape to close the list.
    • how: it opens the list only when the list does not say it is open already, since opening it again closes it.
    • how: it says the pick took only once the list shows the option, or marks it selected.
  • how: each action has a twin, confirm_click, confirm_fill, confirm_select and confirm_upload, with which the page asks me, just under the framed element, whether the agent may act, and the agent is told when I refuse.
    • how: I answer by pressing y, n or t, or clicking them beside the question, purple on white, and only a key or a click of mine counts, not one a script of the page makes.
      • how: s scrolls the element back into view, should I have scrolled away from it.
      • how: qutebrowser goes into insert mode while it asks, so my y or n reach the page, and back after.
      • why: a prompt of qutebrowser took the page’s focus, and closed the menu whose item was asked about.
      • how: the page’s own scripts hear nothing of my presses on the question, so a menu stays open.
    • how: at a question, t trusts rather than accepts once: the frame starts on the element, + widens it and - narrows it, keys found whatever the keyboard’s layout, y trusts the part framed, accepting the action; the actions within it then go unasked, till the page changes.
      • rule: only I trust a part, at a question of mine: the agent has no tool to ask for it.
      • how: the trusted part stays framed, dashed teal, following it as the page scrolls, so I see what goes unasked.
      • how: beside it, ✎ lets me stretch it over its siblings with < and >, widen it to their parent with +, step back with -, or move it to another part I click or go to, my mouse passing over the page without moving it, then keep it with ✓; there, once I pressed i, the keys <, +, -, > and y do as the buttons, y as ✓, and × drops it; only my clicks count there too.
        • scope: one part is trusted at a time: trusting another drops the first.
      • why: a form of many fields asked me once per field.
    • how: a new question takes away one left unanswered, its call told it was withdrawn, since that call may be gone: my harness withdrew the call when I refused it there, and nothing tells the server.
      • how: a call ending takes away its own question only, not one that took its place.
    • how: the frame’s colour tells the action, magenta a click, cyan a fill, green a select, yellow an upload, so the question holds only what goes in, « « … »? », or « ? » for a click.
    • why: my harness grants the one or the other, so I choose, tool by tool, what the agent does unasked.
    • how: the twins wait two minutes for my answer, not the ten seconds a hand-over takes.
    • how: they ask at once, with no second of framing first, since the question already waits for me.

ANSWER_WAIT = 120  # seconds I have to answer


PAGE_SCRIPT = r"""
<<page-script>>
"""


def call(method, *arguments):
    return f"await qutebrowserMcp.{method}({', '.join(json.dumps(argument) for argument in arguments)});"


def run_in_page(calls, directory):
    script = pathlib.Path(directory) / "call.js"
    script.write_text(f"{PAGE_SCRIPT}\n{calls}\n")
    send(f":jseval --quiet --file {script}")


def act(calls, wait=HAND_OVER_WAIT):
    mark = uuid.uuid4().hex
    with tempfile.TemporaryDirectory() as tmp:
        run_in_page(f"qutebrowserMcp.run({json.dumps(mark)}, async () => {{ {calls} }});", tmp)
        return marked(mark, wait)


def marked(mark, wait):
    deadline = time.monotonic() + wait
    while True:
        page, done = page_read(max(wait, HAND_OVER_WAIT))
        if mark in done:
            break
        if time.monotonic() > deadline:
            raise TimeoutError(f"the page never showed the action done, showing {page[:300]!r}")
    if done[mark]:
        raise RuntimeError(done[mark].strip())
    return page


def asking(calls, ask):
    if not ask:
        return act(calls, HAND_OVER_WAIT)
    asker = uuid.uuid4().hex
    send(":mode-enter insert")
    try:
        return act(call("setAsker", asker) + calls, ANSWER_WAIT)
    finally:
        act(call("dismiss", asker))
        send(":mode-leave")


def pressing(page, selector):
    """Press the element SELECTOR picks in PAGE as a mouse would, whatever window has the focus.

    A link qutebrowser follows itself, so that one opening a new tab opens it."""
    element = page.select_one(selector)
    if element.name == "a" and element.get("href") or element.find_parent("a", href=True):
        send(f":click-element --select-first css {shlex.quote(selector)}")
    else:
        act(call("press", selector))


def clicking(selector, ask):
    page = bs4.BeautifulSoup(act(call("clickable", selector)), "html.parser")
    asking(call("click", selector, ask), ask)
    since = settle_from()
    pressing(page, selector)
    act("")
    return settling(f"clicked {selector}", since)


@qutebrowserserver.tool()
@acting
def click(selector: str, why: str) -> str:
    """Click the element SELECTOR picks, once it stood out long enough for me to look at it.

    Prefer confirm_click; WHY says why I need not be asked, shown to me as it clicks."""
    telling(why, f"clicks {selector} unasked", "confirm_click")
    return clicking(selector, ask=False)


@qutebrowserserver.tool()
@acting
def confirm_click(selector: str) -> str:
    """Click the element SELECTOR picks, once it stood out and I accepted the click in qutebrowser."""
    return clicking(selector, ask=True)


def filling(selector, text, ask):
    since = settle_from()
    asking(call("fill", selector, text, ask), ask)
    return settling(f"filled {selector}", since)


@qutebrowserserver.tool()
@acting
def fill(selector: str, text: str, why: str) -> str:
    """Put TEXT in the field SELECTOR picks, in place of what it held, once it stood out.

    Prefer confirm_fill; WHY says why I need not be asked, shown to me as it fills."""
    telling(why, f"fills {selector} unasked", "confirm_fill")
    return filling(selector, text, ask=False)


@qutebrowserserver.tool()
@acting
def confirm_fill(selector: str, text: str) -> str:
    """Put TEXT in the field SELECTOR picks, once it stood out and I accepted the fill in qutebrowser."""
    return filling(selector, text, ask=True)


def selecting(selector, option, ask):
    page = bs4.BeautifulSoup(act(call("clickable", selector)), "html.parser")
    since = settle_from()
    if page.select_one(selector).name == "select":
        asking(call("selectPlain", selector, option, ask), ask)
        return settling(f"selected {option} in {selector}", since)
    asking(call("openList", selector, option, ask), ask)
    if page.select_one(selector).get("aria-expanded") != "true":
        pressing(page, selector)
    try:
        act(call("markOption", selector, option, HAND_OVER_WAIT * 1000 // 2))
    except RuntimeError:
        send(":fake-key <Escape>")
        raise
    act(call("press", "[data-qutebrowser-mcp-option]"))
    act(call("taken", selector, option, 1000))
    return settling(f"selected {option} in {selector}", since)


@qutebrowserserver.tool()
@acting
def select(selector: str, option: str, why: str) -> str:
    """Pick OPTION, by the text shown, in the list or menu SELECTOR opens, in one call rather than two clicks.

    SELECTOR picks a plain select, or the trigger of a list or menu whose items have the option or a menuitem role,
    as most libraries' dropdowns do, Radix ones included; in a menu of checkboxes, each pick toggles one.
    Prefer confirm_select; WHY says why I need not be asked, shown to me as it picks."""
    telling(why, f"picks {option} in {selector} unasked", "confirm_select")
    return selecting(selector, option, ask=False)


@qutebrowserserver.tool()
@acting
def confirm_select(selector: str, option: str) -> str:
    """Like select, once SELECTOR stood out and I accepted the pick in qutebrowser."""
    return selecting(selector, option, ask=True)
what
no page script can choose the file of a file input, and the chooser qutebrowser opens is beyond the agent.
how
so to upload, qutebrowser is told, for this session only, to take its chooser’s answer from a command printing the path, then the input is activated, and the settings go back to theirs once a file input of the page holds a file it did not hold before.
  • why: on AWS, the input took the file while no change reached the document.
  • how: the page focuses the element, and qutebrowser presses space on it, as I would.
    • why: the page opens no chooser for a click by script.
    • why: a click by qutebrowser opened no chooser on the visually hidden input of an AWS form, and a key goes to the focused element, wherever its box.

CHOOSERS = ["fileselect.single_file.command", "fileselect.multiple_files.command"]
CHOSEN_WAIT = 5  # seconds, within the hand-over's ten


def uploading(selector, path, ask):
    since = settle_from()
    send(":set --temp fileselect.handler external")
    for chooser in CHOOSERS:
        send(f":set --temp {chooser} {shlex.quote(json.dumps(['echo', path]))}")
    try:
        asking(call("prepareUpload", selector, path, ask), ask)
        send(":fake-key <Space>")
        act(call("chosen", selector, CHOSEN_WAIT * 1000))
    finally:
        for setting in ["fileselect.handler", *CHOOSERS]:
            send(f":config-unset --temp {setting}")
    return settling(f"uploaded {path} into {selector}", since)


@qutebrowserserver.tool()
@acting
def upload(selector: str, path: str, why: str) -> str:
    """Choose the file at PATH through the chooser a click on SELECTOR opens, once it stood out.

    SELECTOR picks the file input, or the button opening it when the input is hidden.
    Prefer confirm_upload; WHY says why I need not be asked, shown to me as it uploads."""
    telling(why, f"uploads {path} unasked", "confirm_upload")
    return uploading(selector, path, ask=False)


@qutebrowserserver.tool()
@acting
def confirm_upload(selector: str, path: str) -> str:
    """Like upload, once SELECTOR stood out and I accepted the upload in qutebrowser."""
    return uploading(selector, path, ask=True)
how
to open a link, it marks the page it leaves, has qutebrowser open the link, in the tab I see or in a new one made current, and waits for a page without that mark to have loaded its document.
  • why: not its every image: the documentation’s never comes, as some of AWS’s resources never end.
  • how: it checks again and again, each check given a second, since a page moving on drops the check sent to it, which then would use up the whole wait.
    • scope: no story step makes a page drop a check: the AWS page that did, did so only now and then.
  • how: since a page may draw what matters after it loaded, a read-only tool waits for an element to be shown, and says the page’s address and title when it never is.
    • how: it checks again and again, as a load does, so a page moving on, as a sign-in does, loses it no check.
  • how: since no browser tells when a page is done drawing, each click, fill, select, upload and open returns once the page settled, as Playwright’s own MCP server does: loaded, none of its requests pending, and nothing changed in it for half a second; past five seconds it returns all the same, saying why the page had not.
    • how: as there, only its styles, scripts and requests by script are waited for, so that an image that never comes, as on AWS, holds no action back; and loaded means its document read, not its every image.
    • how: as there too, an action awaits only the requests it set off, not those the page made before, and the page keeps every request’s timing, past the 250 it keeps by default, which the AWS console exceeds.
    • how: as Playwright does, the requests are seen from the browser: my qutebrowser configuration notes each one a page starts, and the page’s own timings tell which ended.
    • scope: an embedded frame keeps its timings to itself, so its requests read as pending and the tool waits its seconds out.
  • how: a reload waits for the page the same way, fetched from the server itself when asked, past the cache.
  • how: to show me something, a read-only tool scrolls it into view and frames it in blue for a few seconds.
  • how: when a click opens a page in a new tab, the agent lists the tabs, then makes that one its own, as it would by opening it, so it follows the page.
    • how: it names the tab by its title, so I read which tab it asks for before I grant it; tabs sharing a title are told apart by « 1/2 », « 2/2 »…, in the order they stand.
    • how: a tab a click opens stands right after the one clicked, so the agent takes the tab after its own.
  • how: the link goes to qutebrowser as it is, since :open takes the rest of its line whole: quoted, it read as words to search.

CHECK_WAIT = 1  # seconds a check may take, a check the page drops as it moves on being tried again


def opening(url, command, claiming=False):
    left = uuid.uuid4().hex
    act(call("markLeft", left))
    send(command, linger=OPEN_LINGER)
    if claiming:
        send(":agent-claim")
        AGENT_TAB["opened"] = True
    deadline = time.monotonic() + HAND_OVER_WAIT
    while True:
        try:
            act(call("loaded", left), CHECK_WAIT)
            done = settling(f"opened {url}")
            if claiming:
                title_acting(True)
            return done
        except (RuntimeError, TimeoutError) as error:
            if time.monotonic() > deadline:
                raise TimeoutError(f"{url} never finished loading: {error}") from None
            time.sleep(HAND_OVER_POLL)


@qutebrowserserver.tool(annotations=ToolAnnotations(readOnlyHint=True))
@acting
def readonly_wait_for(selector: str, seconds: int = HAND_OVER_WAIT) -> str:
    """Wait, up to SECONDS, until an element SELECTOR picks is shown; else say where the page stands."""
    deadline = time.monotonic() + seconds
    where = "a page that never answered"
    while time.monotonic() < deadline:
        try:
            act(call("shownIn", selector), CHECK_WAIT)
            return f"{selector} is shown"
        except RuntimeError as error:
            where = str(error)
        except TimeoutError:
            pass
        time.sleep(HAND_OVER_POLL)
    raise TimeoutError(f"no element {selector} shown within {seconds} seconds, on {where}")


STABLE_QUIET = 0.5  # seconds without a change nor a pending request, after which a page has settled
SETTLE_WAIT = 5  # seconds an action waits for the page to settle, before returning all the same


def requested():
    with tempfile.TemporaryDirectory() as tmp:
        answer = pathlib.Path(tmp) / "requests.json"
        send(f":agent-requests {answer}", answer=answer)
        wait_for(answer)
        return answered(answer)


def settle_from():
    """How many requests the page made till now, and when, so that settling waits only for those an action makes."""
    act(call("settleFrom"))
    return len(requested())


def settling(done, since=0):
    """DONE, once the page settled: loaded, none of its requests since SINCE pending, nothing changed for a while."""
    deadline = time.monotonic() + SETTLE_WAIT
    why = "it never answered"
    while time.monotonic() < deadline:
        try:
            asked = requested()
            act(call("settled", asked[since:] if len(asked) >= since else asked, STABLE_QUIET * 1000), CHECK_WAIT)
            return done
        except RuntimeError as error:
            why = str(error)
        except TimeoutError:
            pass
        time.sleep(HAND_OVER_POLL)
    return f"{done}, though the page had not settled within {SETTLE_WAIT} seconds: {why}"


SHOW_WAIT = 5  # seconds what the agent shows me stays framed


@qutebrowserserver.tool(annotations=ToolAnnotations(readOnlyHint=True))
@acting
def readonly_show(selector: str, seconds: int = SHOW_WAIT) -> str:
    """Show me the element SELECTOR picks: scroll it into view and frame it for SECONDS."""
    act(call("show", selector, seconds * 1000), seconds + HAND_OVER_WAIT)
    return f"showed {selector}"


@qutebrowserserver.tool()
@acting
def reload(bypass_cache: bool = False) -> str:
    """Reload the agent's tab, from the server itself with BYPASS_CACHE, once it loaded."""
    opening("the page", ":reload --force" if bypass_cache else ":reload")
    return "reloaded the page"


@qutebrowserserver.tool()
@acting
def open_url(url: str) -> str:
    """Open URL in the agent's tab, once it loaded."""
    return opening(url, f":open {url}")


@qutebrowserserver.tool()
def open_url_in_new_tab(url: str) -> str:
    """Open URL in a new tab, made the agent's, which the other tools then act on, once it loaded."""
    AGENT_TAB["opened"] = True
    title_acting(False)
    AGENT_TAB.clear()
    return opening(url, f":open --tab {url}", claiming=True)


def tabs():
    with tempfile.TemporaryDirectory() as tmp:
        answer = pathlib.Path(tmp) / "tabs.json"
        send(f":agent-tabs {answer}", anywhere=True)
        wait_for(answer)
        listed = json.loads(answer.read_text())
    for tab in listed:
        tab["title"] = tab["title"].removeprefix(AGENT_MARK).strip()
    for tab in listed:
        same = [other for other in listed if other["title"] == tab["title"]]
        tab["name"] = tab["title"] if len(same) == 1 else f"{tab['title']} {same.index(tab) + 1}/{len(same)}"
    return listed


@qutebrowserserver.tool(annotations=ToolAnnotations(readOnlyHint=True))
def readonly_list_tabs() -> str:
    """Every tab qutebrowser has, as JSON: its name, address, and whether it is the agent's.

    A tab's name is its title, followed by « 1/2 », « 2/2 »... when several tabs share it."""
    return json.dumps([{"name": tab["name"], "url": tab["url"], "agent's": tab["agent's"]} for tab in tabs()],
                      ensure_ascii=False)


@qutebrowserserver.tool(annotations=ToolAnnotations(readOnlyHint=True))
def claim_shown_tab() -> str:
    """Make the tab qutebrowser shows the agent's, which the other tools then act on, once it settled."""
    AGENT_TAB.clear()
    send(":agent-claim")
    AGENT_TAB["opened"] = True
    with tempfile.TemporaryDirectory() as tmp:
        script, answer = pathlib.Path(tmp) / "unmark.js", pathlib.Path(tmp) / "unmark.json"
        script.write_text(f"{PAGE_SCRIPT}\nqutebrowserMcp.titleActing(false);\nnull;\n")
        send(f":agent-former-eval {script} {answer}", anywhere=True)
        try:
            wait_for(answer, wait=CHECK_WAIT)
        except TimeoutError:
            pass
    done = settling("claimed the tab shown")
    title_acting(True)
    return done


@qutebrowserserver.tool()
def use_tab(name: str) -> str:
    """Make the tab NAME names, as readonly_list_tabs gives it, the agent's, which the other tools then act on.

    As when a click opened a page in a new tab the agent must follow."""
    titled = [listed for listed in tabs() if listed["name"] == name.strip()]
    if not titled:
        raise RuntimeError(f"no tab is named {name!r}: readonly_list_tabs names them")
    AGENT_TAB["opened"] = True
    title_acting(False)
    AGENT_TAB.clear()
    send(f":agent-take {titled[0]['tab']}", anywhere=True)
    AGENT_TAB["opened"] = True
    done = settling(f"now acting on the tab {name}")
    title_acting(True)
    return done
how
what the page runs lives in one script, loaded with each call, the server only calling into it.

window.qutebrowserMcpState = window.qutebrowserMcpState || {};
window.qutebrowserMcp = {
  state: window.qutebrowserMcpState,
  colours: {click: '#ff00ff', fill: '#00c0ff', select: '#00c000', upload: '#ffd000'},
  highlight: 2000, // milliseconds an element stands out before the agent acts, when nobody is asked
  left: 10000, // milliseconds its frame stays once the agent acted, unless the next action comes first
  poll: 100, // milliseconds between two looks
  kept: 50, // calls whose end the page keeps, for the server to find its own among those running at once

  sleep: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),

  async until(condition, milliseconds) {
    const deadline = Date.now() + milliseconds;
    while (!condition() && Date.now() < deadline) await this.sleep(this.poll);
    return condition();
  },

  shown: element => element.getClientRects().length > 0,

  element(selector) {
    const element = document.querySelector(selector);
    if (!element) throw new Error(`no element matches ${selector}`);
    return element;
  },

  async run(mark, body) {
    let failure = '';
    try {
      await body();
    } catch (error) {
      failure = ' ' + error.message;
    }
    document.documentElement.dataset.qutebrowserMcp = mark + failure;
    this.state.done = this.state.done || {};
    this.state.done[mark] = failure;
    const marks = Object.keys(this.state.done);
    for (const old of marks.slice(0, Math.max(0, marks.length - this.kept))) delete this.state.done[old];
  },

  liveValues() {
    for (const field of document.querySelectorAll('input, textarea')) {
      if (['checkbox', 'radio'].includes(field.type)) field.toggleAttribute('checked', field.checked);
      else if (field.localName == 'textarea') field.textContent = field.value;
      else field.setAttribute('value', field.value);
    }
  },

  markHidden() {
    for (const element of document.querySelectorAll('[data-qutebrowser-mcp-hidden]'))
      element.removeAttribute('data-qutebrowser-mcp-hidden');
    for (const element of document.querySelectorAll('body *'))
      if (!this.shown(element) || getComputedStyle(element).visibility == 'hidden')
        element.setAttribute('data-qutebrowser-mcp-hidden', '');
  },

  frame(element, border, layer) {
    const frame = document.createElement('div');
    const place = () => {
      const box = element.getBoundingClientRect();
      frame.style.cssText = `position: fixed; z-index: ${layer}; pointer-events: none; border: 3px ${border};`
        + ` left: ${box.left - 3}px; top: ${box.top - 3}px; width: ${box.width}px; height: ${box.height}px`;
    };
    place();
    document.documentElement.appendChild(frame);
    return {frame, place};
  },

  trustButtons: {
    still: [['edit', '✎'], ['drop', '×']],
    editing: [['earlier', '<'], ['wider', '+'], ['narrower', '-'], ['later', '>'], ['keep', '✓']],
  },

  trustKeys: {'<': 'earlier', '+': 'wider', '-': 'narrower', '>': 'later', y: 'keep'},

  keepTrusted(path, depth) {
    this.untrust();
    const trust = {path, depth, before: 0, after: 0, history: [], editing: false};
    this.state.trust = trust;
    const part = () => trust.path[trust.depth];
    trust.parts = () => {
      const run = [part()];
      for (let count = 0; count < trust.before && run[0].previousElementSibling; count++)
        run.unshift(run[0].previousElementSibling);
      for (let count = 0; count < trust.after && run.at(-1).nextElementSibling; count++)
        run.push(run.at(-1).nextElementSibling);
      return run;
    };
    const mark = on => {
      for (const element of trust.parts())
        if (on) element.dataset.qutebrowserMcpTrusted = '';
        else delete element.dataset.qutebrowserMcpTrusted;
    };
    const change = update => {
      mark(false);
      update();
      mark(true);
    };
    mark(true);
    trust.frame = document.createElement('div');
    trust.bar = document.createElement('div');
    const buttons = () => {
      trust.bar.textContent = '';
      for (const [name, label] of this.trustButtons[trust.editing ? 'editing' : 'still']) {
        const button = document.createElement('button');
        button.textContent = label;
        button.dataset.qutebrowserMcpTrust = name;
        button.style.cssText = 'background: white; color: #008080; border: 0; font: bold 14px sans-serif; margin: 2px';
        trust.bar.appendChild(button);
      }
    };
    const step = update => change(() => {
      trust.history.push({depth: trust.depth, before: trust.before, after: trust.after});
      update();
    });
    const wider = () => {
      if (trust.depth + 1 < trust.path.length) step(() => { trust.depth++; trust.before = trust.after = 0; });
    };
    const narrower = () => {
      if (trust.history.length) change(() => Object.assign(trust, trust.history.pop()));
      else if (!trust.before && !trust.after && trust.depth > 0) change(() => trust.depth--);
    };
    const earlier = () => {
      if (trust.parts()[0].previousElementSibling) step(() => trust.before++);
    };
    const later = () => {
      if (trust.parts().at(-1).nextElementSibling) step(() => trust.after++);
    };
    const repoint = target => {
      if (!target || trust.bar.contains(target) || target == part()) return;
      change(() => {
        trust.path = [];
        for (let element = target; element && element != document.documentElement; element = element.parentElement)
          trust.path.push(element);
        Object.assign(trust, {depth: 0, before: 0, after: 0, history: []});
      });
    };
    trust.pointed = event => {
      if (trust.editing && event.isTrusted) repoint(event.target);
    };
    trust.guard = event => {
      if (!trust.bar.contains(event.target)) {
        if (!trust.editing || !event.isTrusted) return;
        event.stopImmediatePropagation();
        event.preventDefault();
        if (event.type == 'click') repoint(event.target);
        return;
      }
      event.stopImmediatePropagation();
      event.preventDefault();
      if (!event.isTrusted || event.type != 'click') return;
      press(event.target.closest('[data-qutebrowser-mcp-trust]')?.dataset.qutebrowserMcpTrust);
    };
    const press = name => {
      if (name == 'drop') return this.untrust();
      if (name == 'edit') trust.editing = true;
      if (name == 'earlier') earlier();
      if (name == 'wider') wider();
      if (name == 'narrower') narrower();
      if (name == 'later') later();
      if (name == 'keep') trust.editing = false;
      buttons();
    };
    trust.keyed = event => {
      const name = this.trustKeys[event.key];
      if (!trust.editing || !event.isTrusted || !name) return;
      event.stopImmediatePropagation();
      event.preventDefault();
      if (event.type == 'keydown') press(name);
    };
    for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'])
      window.addEventListener(type, trust.guard, true);
    for (const type of ['keydown', 'keypress', 'keyup'])
      window.addEventListener(type, trust.keyed, true);
    window.addEventListener('focusin', trust.pointed, true);
    const follow = () => {
      if (this.state.trust != trust) return;
      if (!part().isConnected) return this.untrust();
      const boxes = trust.parts().map(element => element.getBoundingClientRect()).filter(box => box.width || box.height);
      const left = Math.min(...boxes.map(box => box.left)), top = Math.min(...boxes.map(box => box.top));
      const box = {left, top, width: Math.max(...boxes.map(box => box.right)) - left,
                   height: Math.max(...boxes.map(box => box.bottom)) - top};
      const border = trust.editing ? 'dashed #800080' : 'dashed #008080';
      trust.frame.style.cssText = `position: fixed; z-index: 2147483645; pointer-events: none; border: 3px ${border};`
        + ` left: ${box.left - 3}px; top: ${box.top - 3}px; width: ${box.width}px; height: ${box.height}px`;
      trust.bar.style.cssText = 'position: fixed; z-index: 2147483647; background: #008080;'
        + ` left: ${Math.max(0, box.left - 3)}px; top: ${Math.max(0, box.top - 30)}px`;
      requestAnimationFrame(follow);
    };
    buttons();
    document.documentElement.append(trust.frame, trust.bar);
    follow();
  },

  untrust() {
    const trust = this.state.trust;
    if (!trust) return;
    this.state.trust = null;
    for (const element of trust.parts()) delete element.dataset.qutebrowserMcpTrusted;
    trust.frame.remove();
    trust.bar.remove();
    for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'])
      window.removeEventListener(type, trust.guard, true);
    for (const type of ['keydown', 'keypress', 'keyup'])
      window.removeEventListener(type, trust.keyed, true);
    window.removeEventListener('focusin', trust.pointed, true);
  },

  ask(element, question) {
    return new Promise(resolve => {
      const panel = document.createElement('div');
      const box = element.getBoundingClientRect();
      panel.dataset.qutebrowserMcpQuestion = '';
      panel.style.cssText = 'position: fixed; z-index: 2147483647; background: #800080; color: white;'
        + ` font: 14px sans-serif; padding: 6px; left: ${box.left}px; top: ${box.bottom + 6}px`;
      const keys = document.createElement('input');
      keys.style.cssText = 'width: 1px; height: 1px; opacity: 0; border: 0; padding: 0';
      const say = (text, answers) => {
        panel.textContent = text + ' ';
        for (const answer of answers) {
          const button = document.createElement('button');
          button.textContent = answer;
          button.dataset.qutebrowserMcpAnswer = answer;
          button.style.cssText = 'background: white; color: #800080; border: 0; font: bold 14px sans-serif; margin-left: 6px';
          panel.appendChild(button);
        }
        panel.appendChild(keys);
        keys.focus({preventScroll: true});
      };
      const path = [];
      let depth = 0, trusting = null;
      const before = document.activeElement;
      const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', 'focusin', 'focusout', 'blur',
                     'keydown', 'keypress', 'keyup', 'input'];
      const answered = answer => {
        this.state.dismiss = null;
        for (const type of types) window.removeEventListener(type, guard, true);
        panel.remove();
        if (trusting) trusting.frame.remove();
        if (before) before.focus({preventScroll: true});
        resolve(answer);
      };
      const pressed = key => {
        if (key == 's') return element.scrollIntoView({block: 'center'});
        if (key == 'n') return answered(false);
        if (!trusting) {
          if (key == 'y') return answered(true);
          if (key != 't') return;
          for (let part = element; part && part != document.documentElement; part = part.parentElement) path.push(part);
          trusting = this.frame(element, 'dashed #800080', 2147483646);
        } else {
          if (key == '+' && depth < path.length - 1) depth++;
          if (key == '-' && depth > 0) depth--;
          if (key == 'y') {
            this.keepTrusted(path, depth);
            return answered(true);
          }
          trusting.frame.remove();
          trusting = this.frame(path[depth], 'dashed #800080', 2147483646);
        }
        say('trust: + wider, - narrower, y trust, n refuse', ['+', '-', 'y', 'n']);
      };
      const guard = event => {
        if (!panel.contains(event.target) && !panel.contains(event.relatedTarget)) return;
        event.stopImmediatePropagation();
        if (!['focusin', 'focusout', 'blur'].includes(event.type)) event.preventDefault();
        if (!event.isTrusted) return;
        const button = event.target.closest && event.target.closest('[data-qutebrowser-mcp-answer]');
        if (event.type == 'click' && button) pressed(button.dataset.qutebrowserMcpAnswer);
        if (event.type == 'keydown') pressed(event.key);
      };
      if (this.state.dismiss) this.state.dismiss();
      this.state.dismiss = () => answered(null);
      for (const type of types) window.addEventListener(type, guard, true);
      document.documentElement.appendChild(panel);
      say(question, ['y', 'n', 't', 's']);
    });
  },

  async highlighted(selector, doing, value, ask) {
    this.leave();
    const element = this.element(selector);
    element.scrollIntoView({block: 'center'});
    const {frame, place} = this.frame(element, `solid ${this.colours[doing]}`, 2147483647);
    try {
      if (!ask) await this.sleep(this.highlight);
      else if (!element.closest('[data-qutebrowser-mcp-trusted]')) {
        const answer = await this.ask(element, `${value}?`);
        if (answer === null) throw new Error('the question was withdrawn, as another took its place');
        if (!answer) throw new Error(`you refused: ${doing} ${value}, at ${selector}`);
      }
    } catch (error) {
      frame.remove();
      throw error;
    }
    this.leaving(element, frame, place);
    return element;
  },

  leaving(element, frame, place) {
    const timer = setTimeout(() => this.leave(), this.left);
    const gone = setInterval(() => {
      if (!element.isConnected || !this.shown(element)) this.leave();
    }, this.poll);
    window.addEventListener('scroll', place, true);
    window.addEventListener('resize', place);
    this.state.leave = () => {
      clearTimeout(timer);
      clearInterval(gone);
      window.removeEventListener('scroll', place, true);
      window.removeEventListener('resize', place);
      frame.remove();
      delete this.state.leave;
    };
  },

  leave() {
    if (this.state.leave) this.state.leave();
  },

  titleActing(acting) {
    const mark = '🤖';
    const keep = () => {
      if (this.state.acting && !document.title.startsWith(mark))
        document.title = `${mark} ${document.title || location.host + location.pathname}`;
    };
    this.state.acting = acting;
    if (acting && !this.state.titleWatch) {
      this.state.titleWatch = new MutationObserver(keep);
      this.state.titleWatch.observe(document.head || document.documentElement,
                                    {subtree: true, childList: true, characterData: true});
    }
    if (acting) keep();
    else while (document.title.startsWith(mark)) document.title = document.title.slice(mark.length).trimStart();
  },

  async show(selector, milliseconds) {
    this.leave();
    const element = this.element(selector);
    element.scrollIntoView({block: 'center'});
    const {frame} = this.frame(element, 'solid #0000ff', 2147483647);
    await this.sleep(milliseconds);
    frame.remove();
  },

  setAsker(asker) {
    this.state.asker = asker;
  },

  dismiss(asker) {
    if (this.state.asker == asker && this.state.dismiss) this.state.dismiss();
  },

  press(selector) {
    const element = this.element(selector);
    const box = element.getBoundingClientRect();
    const at = {bubbles: true, cancelable: true, composed: true, view: window, button: 0,
                clientX: box.left + box.width / 2, clientY: box.top + box.height / 2};
    const pointer = {...at, pointerId: 1, pointerType: 'mouse', isPrimary: true};
    element.dispatchEvent(new PointerEvent('pointerover', {...pointer, buttons: 0}));
    element.dispatchEvent(new PointerEvent('pointerenter', {...pointer, bubbles: false, buttons: 0}));
    element.dispatchEvent(new MouseEvent('mouseover', {...at, buttons: 0}));
    element.dispatchEvent(new PointerEvent('pointermove', {...pointer, buttons: 0}));
    element.dispatchEvent(new MouseEvent('mousemove', {...at, buttons: 0}));
    element.dispatchEvent(new PointerEvent('pointerdown', {...pointer, buttons: 1}));
    element.dispatchEvent(new MouseEvent('mousedown', {...at, buttons: 1}));
    element.dispatchEvent(new PointerEvent('pointerup', {...pointer, buttons: 0}));
    element.dispatchEvent(new MouseEvent('mouseup', {...at, buttons: 0}));
    element.dispatchEvent(new MouseEvent('click', {...at, buttons: 0}));
  },

  clickable(selector) {
    const element = this.element(selector);
    if (this.shown(element)) return;
    for (let section = element; section; section = section.parentElement) {
      const opener = section.id && `[aria-controls~=${JSON.stringify(section.id)}]`;
      if (opener && document.querySelector(opener))
        throw new Error(`${selector} is not shown, being in a closed section, which ${opener} opens`);
    }
    throw new Error(`${selector} is not shown, so no mouse reaches it`);
  },

  async click(selector, ask) {
    await this.highlighted(selector, 'click', '', ask);
  },

  async fill(selector, text, ask) {
    const element = await this.highlighted(selector, 'fill', `« ${text} »`, ask);
    Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value').set.call(element, text);
    element.dispatchEvent(new Event('input', {bubbles: true}));
  },

  named: (element, name) => [element, ...element.querySelectorAll('*')]
    .some(part => part.textContent.trim().toLowerCase() == name.toLowerCase()),

  async selectPlain(selector, name, ask) {
    const element = await this.highlighted(selector, 'select', `« ${name} »`, ask);
    const option = [...element.options].find(option => this.named(option, name));
    if (!option) throw new Error(`${selector} has no option ${name}`);
    element.value = option.value;
    element.dispatchEvent(new Event('input', {bubbles: true}));
    element.dispatchEvent(new Event('change', {bubbles: true}));
  },

  async openList(selector, name, ask) {
    await this.highlighted(selector, 'select', `« ${name} »`, ask);
  },

  shownOptions() {
    return [...document.querySelectorAll('[role=option], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio]')]
      .filter(element => this.shown(element));
  },

  async markOption(selector, name, milliseconds) {
    const found = () => this.shownOptions().find(option => this.named(option, name));
    if (!await this.until(found, milliseconds))
      throw new Error(`${selector} opened no shown option ${name}; it shows: `
                      + this.shownOptions().map(option => option.textContent.trim()).join(' | '));
    for (const option of document.querySelectorAll('[data-qutebrowser-mcp-option]'))
      option.removeAttribute('data-qutebrowser-mcp-option');
    found().setAttribute('data-qutebrowser-mcp-option', found().getAttribute('aria-checked') || '');
  },

  async taken(selector, name, milliseconds) {
    const trigger = this.element(selector);
    const option = document.querySelector('[data-qutebrowser-mcp-option]');
    const checked = option?.getAttribute('data-qutebrowser-mcp-option');
    const took = () => option?.getAttribute('aria-selected') == 'true'
      || checked && option.getAttribute('aria-checked') != checked
      || trigger.textContent.toLowerCase().includes(name.toLowerCase())
      || (option?.getAttribute('role') || '').startsWith('menuitem') && !this.shown(option);
    if (!await this.until(took, milliseconds))
      throw new Error(`${name} was clicked, yet ${selector} neither shows it nor marks it selected`);
  },

  async prepareUpload(selector, path, ask) {
    const element = await this.highlighted(selector, 'upload', path, ask);
    this.state.files = new Map([...document.querySelectorAll('input[type=file]')].map(input => [input, input.value]));
    element.focus();
  },

  async chosen(selector, milliseconds) {
    const chosen = () => [...document.querySelectorAll('input[type=file]')]
      .some(input => input.value && input.value != this.state.files.get(input));
    if (!await this.until(chosen, milliseconds))
      throw new Error(`qutebrowser pressed space on ${selector}, yet no file input took a file within `
                      + `${milliseconds / 1000} seconds: name the file input, or the element that opens its chooser`);
  },

  shownIn(selector) {
    if (![...document.querySelectorAll(selector)].some(element => this.shown(element)))
      throw new Error(`${location.href}, titled ${document.title}`);
  },

  markLeft(left) {
    document.documentElement.dataset.qutebrowserMcpLeft = left;
  },

  loaded(left) {
    if (document.documentElement.dataset.qutebrowserMcpLeft == left) throw new Error('the page left is still shown');
    if (location.href == 'about:blank') throw new Error('the page has not begun loading');
    if (document.readyState == 'loading') throw new Error('the page is still loading');
  },

  settleFrom() {
    performance.setResourceTimingBufferSize(100000);
    this.state.settleFrom = performance.now();
  },

  settled(requested, quiet) {
    if (!this.state.stirring) {
      this.state.stirred = Date.now();
      this.state.stirring = new MutationObserver(records => {
        if (records.some(record => !(record.attributeName || '').startsWith('data-qutebrowser-mcp')))
          this.state.stirred = Date.now();
      });
      this.state.stirring.observe(document.documentElement,
                                  {subtree: true, childList: true, attributes: true, characterData: true});
    }
    const ended = performance.getEntriesByType('resource')
      .filter(entry => entry.startTime >= (this.state.settleFrom || 0)).map(entry => entry.name);
    const pending = requested.filter(url => {
      const at = ended.indexOf(url);
      if (at >= 0) ended.splice(at, 1);
      return at < 0;
    });
    if (pending.length) this.state.stirred = Date.now();
    if (document.readyState == 'loading') throw new Error('it is still loading');
    if (pending.length) throw new Error(`it still waits for ${pending.join(', ')}`);
    if (Date.now() - this.state.stirred < quiet) throw new Error('it still changes');
  },
};

  1. qutebrowser 3.7.0, in misc/ipc.py: « Try to send a commandline to a running instance. » and « socketname: The name which should be used for the socket. », and in app.py: « URLs to open have no prefix, commands to execute begin with a colon. » and « via_ipc: Whether the arguments were transmitted over IPC. » ↩︎

  2. qutebrowser 3.7.0, in misc/ipc.py: « parts_to_hash = [getpass.getuser()] » and « return os.path.join(standarddir.runtime(), filename) », and in utils/standarddir.py: « typ = QStandardPaths.StandardLocation.RuntimeLocation » and « # _writable_location makes sure we have a qutebrowser-specific subdir. » ↩︎

  3. qutebrowser 3.7.0, in misc/ipc.py, its own client writes and hangs up: « socket.writeData(data) », then « socket.disconnectFromServer() », reading nothing back. ↩︎