Repository navigation
2026 10 27 live control
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A person who takes Control in the live view holds the browser. The agent's running call finishes first. After it:
- every surface's browser-changing call is refused with a 409 that names
await_control; - reads still answer;
-
await_controlreturns the moment they hand back, with what they did. It runs as an MCP task, or as a capped blocking call.
Architecture:
- A plain
live/control.pyowns the episode per workspace (requested → held → released). It also owns a thread-safe gate keyed by Grid id, which the call hosts read after taking their turn on the session's lock. - The grant waits for a mark in the lock's queue, and owes the session monitor a new
controlreason. That reason's events reach a per-reason sink,live/pages.py, which lists the pages the person reached. - The MCP notice gains a structured
controlfield. -
await_controlis one async body, served by an async MCP tool withTaskConfig(mode="optional")and by a long-poll route.
Tech Stack: Python 3.11+, FastMCP 4.1.4 with fastmcp[tasks] (fastmcp-tasks 4.1.4, pydocket, memory://), Starlette, asyncio/anyio, WebDriver BiDi through bidi/, pytest with asyncio_mode = "auto"; Svelte 5, Vitest.
Spec: docs/superpowers/specs/2026-10-27-live-control-design.md. The issue's draft, finished on this branch rather than replaced:
- round 1 checked it against the code and added rulings 21–33;
- it corrected rulings 11 and 14, and fixed the refusal's words (24);
- nothing is open.
The spec is the design. This plan settles these on top of it:
-
Modules.
-
live/control.py:Controls,Episodeand the asyncawait_controlbody. Plain: no Selenium, no protocol imports.workspacesand the lock mark are injected. -
live/pages.py:Pages, thecontrolreason's monitor sink. -
live/tunnels.py: keeps the tunnel registry and delegates control to aControlsit is given. Its notice copy moves tolive/control.py. -
mcp/waiting.py: the R6 helper. -
faults.Controlledis the refusal.
-
-
Who calls whom.
Tunnel→Tunnelshooks →Controls.Controls→Monitor.attach_here/release, and the injectedbehind(grid_id). Hosts →Calls.begin(tool=)→Controls.refusal.Actions._atandscreenshot→actions.control.refusal(session_id, "navigate"). Nothing belowmcp/importslive/.server.pywiresactions.controlandWorkspaces(control=), as it wiresactions.capturetoday. -
Order of construction in
server.py:-
Pages; -
Monitor(sinks={"capture": capture, "control": pages}); -
Controls(monitor=, pages=, behind=locks.behind); -
Tunnels(controls); -
Workspaces(..., control=controls); -
actions.control = controls.
-
- Python 3.11 is the floor (
CONTRIBUTING.md): nothing 3.12+. -
live/stays plain. It imports nofastmcp,starlette,mcp,uvicornorselenium, and nothing frommcp,http,routes,serverorspec.live.controlandlive.pagesjoinNO_SELENIUMintests/test_boundaries.py.anyiois allowed, as inmonitor/. - No log line formats a Grid id, a page URL, a title or paste text (
test_no_log_line_formats_a_grid_id). The page list is never logged. - No thread ever blocks on the session's lock for the grant (spec ruling 25).
- The copy, verbatim:
-
refusal, held:
{who} has control of this browser since {HH:MM} UTC. Call await_control (POST /browser/await-control), which returns when they hand back. -
refusal, requested:
{who} asked for control of this browser at {HH:MM} UTC. Call await_control (POST /browser/await-control), which returns when they hand back. -
notice on the in-flight call (
state: "requested"):{who} asked for control of this browser at {HH:MM} UTC; it is theirs once this call ends. Call await_control, which returns when they hand back. -
notice on a read while held (
state: "held"): the held refusal sentence. -
notice, unsaid hand-back (
state: "handed_back"):control of this browser was handed back at {HH:MM} UTC; the page may have changed. Call await_control for what they did, or look at the page before carrying on. -
await_control at the cap:
still in {who}'s control; call again. -
await_control with nobody in control:
nobody has control of this browser: carry on. -
await_control after the browser ended:
the browser ended while {who} had control: call open_session. -
the viewer while requested:
waiting for the agent's call to finish. -
{who}:adminandopenbecomea person; distinct names are joined withand.
-
refusal, held:
- The values:
live.await_cap90 (ge=30); grant poll 0.1 s;TaskConfig(mode="optional", poll_interval=timedelta(seconds=1));TasksExtension(url="memory://"); pages capped atHISTORY_CAP(100); a title read bounded byREPLY_TIMEOUT; the grant's attach bounded byATTACH_TIMEOUT(15 s). - Every published shape that moves is a golden. Regenerate with
GOLDEN_UPDATE=1 pytest, read the diff, and commit the goldens on their own (CONTRIBUTING.md). - The wiki is regenerated with
python scripts/generate_wiki.py, committed insidedocs/with the pointer bump, andtests/test_wiki.pypasses. - Changelog: one line under
## [Unreleased]. - No new integration flow (AGENTS.md: less is more).
- Never commit
kubed/selenium_flow/http/static/oropenapi.yaml. Never edit.github/. - The checks, as CI runs them. Each task's last step runs the ones its files touch, and Task 12 runs them all:
- Python (
test.yml):ruff check .andpytest; - UI (
ui.yml):npm --prefix ui ci,npm --prefix ui run lint,npm --prefix ui run check,npm --prefix ui test,npm --prefix ui run build; -
quality.yml:python scripts/generate_openapi.pysucceeds, andpip-auditpasses withfastmcp[tasks]installed; -
pr.yml: a new[Unreleased]entry.
- Python (
-
A flow running when a person clicks Control, with
onError: continueon its next step. A person expects the run to stop there with the refusal, not to skip to step 3 and keep driving the browser under their hands. Task 6 pins it. -
A read that names a
url, such asextract(url=…)while the browser is elsewhere, or ascreenshot(width=1600). A person expects their page and window to stay as they left them. Task 6 pins both refusals, and thatextractwith the current URL answers. -
The person closes the tab while Control is only requested. The agent was told to wait. A person expects the agent's
await_controlto return, and its next call to be allowed. They do not expect a gate left shut with nobody holding it. Task 4 pins the withdrawn episode. -
The server stops while a grant waits behind a 900 s
assert. A person expects the pod to stop promptly. Task 3 pins that the mark takes no thread. Task 4 pins thatControls.stop()cancels the pending grant and every waiter returns. -
A Tasks client cancels
await_control, or a blocking client disconnects mid-wait. A person expects nothing left behind: the episode still holds, and the next refusal still names it. Task 9 pins a cancelled wait leaving the episode and its event untouched.
Files:
- Modify:
pyproject.toml(dependencies:fastmcp[tasks]>=4.1.4,<5replacesfastmcp>=4.0.0,<5) - Create:
tests/test_tasks_contract.py
Interfaces:
-
Produces: the facts below, as passing tests, for Tasks 8 and 9. If a fact fails against what installs, stop and report which one. Do not design around it in place: Tasks 8 and 9 change with it.
-
Step 1: Write the tests against a toy
FastMCPserver in the test file. It has one async tool withtask=TaskConfig(mode="optional", poll_interval=timedelta(seconds=1)), one middleware recording whatcall_nextreturned, andmcp.add_extension(TasksExtension(url="memory://")):
def test_task_on_a_sync_function_is_refused(): ... # ValueError at decoration (R6)
async def test_call_next_returns_a_create_task_result(): ... # mode="auto": the middleware saw CreateTaskResult (R1)
async def test_a_legacy_client_runs_the_tool_inline(): ... # mode="legacy": a ToolResult, no task
async def test_the_worker_has_no_request_but_the_headers(): ... # in the tool: get_http_request raises; get_http_headers has a header the middleware set at submission (R3)
async def test_the_worker_runs_on_the_server_loop(): ... # an asyncio.Event made on the server's loop wakes the task
async def test_structured_content_survives_tasks_get(): ... # result().structured_content == the tool's dict (R2)
async def test_the_poll_interval_is_one_second(): ... # the CreateTaskResult's pollIntervalMs == 1000 (R4)
async def test_cancel_cancels_the_wait(): ... # tasks/cancel → the tool sees CancelledError- Step 2: Install and run.
Run: pip install -e ".[test]" && pytest tests/test_tasks_contract.py -v
Expected: PASS. A failure is a finding: report it.
- Step 3: Check the audit and the package.
Run: pip-audit (as quality.yml runs it) and ruff check .
Expected: no finding on fastmcp-tasks, pydocket or burner-redis.
- Step 4: Commit
build: take fastmcp[tasks], with the facts await_control rests on.
Files:
-
Modify:
kubed/selenium_flow/live/tunnel.py(run'sfinally) -
Test:
tests/test_live_tunnel.py -
Step 1: Write the failing test.
async def test_a_second_cancellation_during_teardown_still_closes_both_sockets():
# a reader task that ignores its first cancel and blocks;
# cancel run(), then cancel it again while it waits for the tasks
assert viewer.closed_with is not None and upstream.closed-
Step 2: Run it to see it fail.
pytest tests/test_live_tunnel.py -v -k second_cancellation. Expected: FAIL, the viewer is not closed. -
Step 3: Implement.
await asyncio.wait(tasks)goes insidetry:, andawait self._close(self._ending or STOPPING)goes in itsfinally:. Nothing else changes;_close's awaits are already bounded. -
Step 4: Run the live tests.
pytest tests/test_live_tunnel.py tests/test_live_tunnels.py tests/test_admin_live.py -v && ruff check .Expected: PASS. -
Step 5: Commit
fix: a live tunnel cancelled twice still closes both sockets.
Files:
- Modify:
kubed/selenium_flow/faults.py(Controlled) - Modify:
kubed/selenium_flow/errors.py(status_for: 409 first) - Modify:
kubed/selenium_flow/spec/builder.py("409"in every/browseroperation'sresponses, beside"404"at 233; not on/browserGET) - Modify:
kubed/selenium_flow/core/capabilities.py(Capability.under_control,Capability.waits; the reads set"answers";UNDER_CONTROLview) - Modify:
kubed/selenium_flow/workspace/locks.py(Turns.mark,Turns.passed,behind) - Test:
tests/test_errors.py,tests/test_surfaces.py,tests/test_locks.py; goldenstests/golden/(errors, OpenAPI)
Interfaces:
- Produces:
# faults.py
class Controlled(Exception):
"""A person holds this workspace's browser; a 409 (spec 2026-10-27-live-control)."""
def __init__(self, message: str): ... # str(exc) is the refusal sentence
# core/capabilities.py
under_control: Literal["answers", "refused"] = "refused" # new column
waits: bool = False # served by its own body on both surfaces
UNDER_CONTROL: dict[str, str] # name -> column, read off the table
# answers: extract, outline, screenshot, print, network_body, await_control (Task 9 adds that row)
# workspace/locks.py
class Turns:
def mark(self) -> int # take a ticket and abandon it at once; never waits
def passed(self, ticket: int) -> bool # every call that asked before `ticket` has had its turn
def behind(browser: str) -> Callable[[], bool]
# mark this browser's queue now; the callable keeps the Hold alive and answers `passed`- Step 1: Write the failing tests.
# test_errors.py
def test_a_controlled_browser_is_a_409():
assert status_for(Controlled("kelly has control")) == 409
# test_surfaces.py
def test_what_answers_under_control():
assert {n for n, v in UNDER_CONTROL.items() if v == "answers"} == {
"extract", "outline", "screenshot", "print", "network_body"} # await_control joins in Task 9
# test_locks.py
def test_a_mark_on_an_idle_browser_has_passed_at_once():
assert behind("g1")()
def test_a_mark_passes_when_the_call_before_it_lets_go():
# thread A holds driving("g1"); passed = behind("g1"); assert not passed(); A releases; assert passed()
def test_a_call_after_the_mark_does_not_wait_for_it():
# mark while A holds; B queues after the mark; A releases; B gets its turn at once
def test_a_mark_holds_no_thread():
# threading.active_count() unchanged across behind() while A holdsThe OpenAPI golden test fails until Step 4 regenerates it.
-
Step 2: Run them to see them fail.
pytest tests/test_errors.py tests/test_surfaces.py tests/test_locks.py -v. Expected: FAIL (Controlled,UNDER_CONTROLandbehindare undefined). -
Step 3: Implement.
-
Turns.marktakesself._next, adds it to_abandonedand runs_skip_abandoned(), all under_changed. -
passed(t)isself._serving > t. - The 409's description in
spec/builder.py:A person holds this workspace's browser in the live view. Call POST /browser/await-control, then retry.
-
-
Step 4: Regenerate the goldens and run.
GOLDEN_UPDATE=1 pytest, read the diff undertests/golden/, thenpython scripts/generate_openapi.py && pytest && ruff check .. Expected: PASS; the diff adds only the 409s. -
Step 5: Commit the code as
feat: a 409 for a browser a person holds, and which capabilities answer then, then the goldens on their own.
Files:
- Create:
kubed/selenium_flow/live/control.py - Modify:
kubed/selenium_flow/live/tunnels.py(delegates control;TAKEN/HANDED_BACKmove out) - Modify:
kubed/selenium_flow/live/tunnel.py(modegains"requested"; the gate is fed"control"only when held;grant()) - Modify:
kubed/selenium_flow/live/audit.py(asked, besideswitched) - Modify:
kubed/selenium_flow/http/admin/live.py(Tunnel no longer takesmay_control; paste askscontrols.controlling) - Modify:
kubed/selenium_flow/server.py(Controls, thenTunnels(controls);controls.stop()in the lifespan beforemonitor.stop()) - Modify:
tests/test_boundaries.py(live.controlinNO_SELENIUM) - Test:
tests/test_live_control.py(new),tests/test_live_tunnel.py,tests/test_live_tunnels.py,tests/test_admin_live.py
Interfaces:
- Consumes:
locks.behind(Task 3),faults.Controlled(Task 3). - Produces:
# live/control.py
@dataclass(frozen=True)
class Gate: # what a worker thread reads; replaced whole, never mutated
workspace: str
who: tuple[str, ...]
since: float
state: Literal["requested", "held"]
@dataclass
class Episode:
workspace: str; grid_id: str; who: list[str]; since: float
state: Literal["requested", "held", "released"]
granted: float | None = None; until: float | None = None
ended: bool = False # the browser ended under it
followed: bool = False # pages listened for (Task 7)
done: asyncio.Event # set at the release
class Controls:
def __init__(self, *, behind: Callable[[str], Callable[[], bool]], monitor=None,
pages=None, clock=time.time, poll: float = 0.1): ...
def ask(self, tunnel) -> Literal["control", "requested"] # on the loop
def withdraw(self, tunnel) -> None # Watch, or the tunnel ended
def ended(self, grid_id: str) -> None # session.ended: release, ended=True
def refusal(self, session_id: str, tool: str) -> Controlled | None # any thread
def controlling(self, workspace: str, who: str) -> bool
def notice(self, workspace: str) -> dict | None # {"state","who","since","hint"}; pops an unsaid hand-back
async def wait(self, workspace: str, timeout: float | None) -> Waited # Task 9 uses it
def stop(self) -> None # cancel pending grants, release every episode
def said(who: Iterable[str]) -> str # "a person" for admin/open, " and "-joined- Step 1: Write the failing tests in
tests/test_live_control.py. They use fake tunnels (workspace,who,grid_id, an asyncgrant()that records) and a fakebehindwhose answer the test flips:
async def test_with_no_call_running_control_is_granted_at_once():
assert controls.ask(t1) == "control" and controls.refusal("g1", "navigate") is not None
async def test_with_a_call_running_control_is_requested_then_granted():
passed.value = False
assert controls.ask(t1) == "requested"
assert "asked for control" in str(controls.refusal("g1", "interact"))
passed.value = True; await asyncio.sleep(0.15)
assert t1.granted and "has control" in str(controls.refusal("g1", "interact"))
async def test_reads_and_paste_are_never_refused():
controls.ask(t1)
assert controls.refusal("g1", "extract") is None and controls.refusal("g1", "paste") is None
async def test_leaving_while_requested_ends_the_episode(): # ruling 23
passed.value = False; controls.ask(t1); controls.withdraw(t1)
assert controls.refusal("g1", "navigate") is None
assert controls.notice("w1")["state"] == "handed_back"
async def test_control_is_held_while_any_viewer_holds_it():
controls.ask(t1); controls.ask(t2); controls.withdraw(t1)
assert controls.refusal("g1", "navigate") is not None
controls.withdraw(t2)
assert controls.refusal("g1", "navigate") is None
async def test_the_browser_ending_releases_with_ended(): ...
async def test_stop_cancels_a_pending_grant_and_sets_every_event(): ... # Review Focus 4
def test_the_refusal_says_who_and_when():
assert str(refusal) == ("a person has control of this browser since 15:12 UTC. Call "
"await_control (POST /browser/await-control), which returns when they hand back.")In tests/test_live_tunnel.py:
async def test_a_requested_viewer_is_told_requested_and_stays_watch_at_the_gate(): ...
async def test_a_grant_sends_control_and_opens_the_gate(): ...-
Step 2: Run them to see them fail.
pytest tests/test_live_control.py tests/test_live_tunnel.py -v. Expected: FAIL, no modulelive.control. -
Step 3: Implement.
-
Asking.
askstarts or joins the episode and replaces the gate at once. It callsbehind(grid_id)once per episode: already passed means_grantnow; otherwise a task polls everypollseconds, then grants. -
_grantsetsheldandgranted, replaces the gate, and awaits each requesting tunnel'sgrant(). Task 7 adds the monitor's attach before it. -
The release.
withdrawof the last asker setsreleased,until, keeps the episode as the workspace's unsaid hand-back, clears the gate and setsdone. -
Tunnel._controlonmode: control, once piping, asksself.on_ask(), whichTunnels.addwires tocontrols.ask. It sends what that returns.grant()setsmode = "control", sends it and auditsswitched. -
Tunnels.on_modeto watch andon_endcallwithdraw.Tunnels.endedalso callscontrols.ended(event.session_id).Tunnels.noticeandcontrollingdelegate.
-
Asking.
-
Step 4: Run the live tests.
pytest tests/test_live_control.py tests/test_live_tunnel.py tests/test_live_tunnels.py tests/test_admin_live.py tests/test_control_notice.py tests/test_boundaries.py -v && ruff check .Expected: PASS.test_control_notice.pyis updated to the new copy in Task 8; mark its copy assertionsxfailhere only if they block, and remove the mark there. -
Step 5: Commit
feat: Control is requested while the agent's call runs, and granted after it.
Files:
- Modify:
kubed/selenium_flow/workspace/calls.py(Calls(control=);begin(..., tool: str)checks first) - Modify:
kubed/selenium_flow/workspace/workspaces.py:-
_saidalways takeslocks.driving; -
Workspaces(control=)passes it toCalls; -
open_browserandend_browsercheckcontrol.refusal(record.session_id, name)before acting; -
pastepassestool="paste", as it does.
-
- Modify:
kubed/selenium_flow/secrets.py(perform_write:calls.begin(..., tool="write")) - Modify:
kubed/selenium_flow/flows/api.py(step_startedpassesentry["tool"]) - Modify:
kubed/selenium_flow/flows/engine.py(_drive: afaults.Controlledstops the run likeCancelled) - Modify:
kubed/selenium_flow/server.py(Workspaces(..., control=controls)) - Test:
tests/test_workspaces.py(or the file that ownsact),tests/test_flow_engine.py(or the file that owns_drive),tests/test_secrets*.py
Interfaces:
-
Consumes:
Controls.refusal(Task 4), duck-typed: anything withrefusal(session_id, tool) -> Exception | None. -
Produces:
Calls.begin(workspace, session_id, *, browser="", tool: str) -> Begun, raising the refusal beforemonitor.calling. -
Step 1: Write the failing tests. Use a fake control whose
refusalreturnsControlled(...)for a Grid id, andNonefor reads:
def test_an_acting_call_is_refused_and_never_begun(): # monitor.calling not called; no call.finished
def test_a_read_answers_while_controlled():
def test_a_call_queued_before_the_request_is_refused_when_its_turn_comes():
# A holds driving(g1); B enters act() and waits; the gate shuts; A releases → B raises Controlled
def test_open_session_and_end_browser_are_refused_while_controlled():
def test_a_bound_write_is_refused_before_the_page_is_read(): # actions.page never called
def test_a_flow_stops_at_a_refused_step_even_with_on_error_continue(): # Review Focus 1
# step 2 refused, onError: continue → status "failed", step 3 never dispatched,
# step 2's error is the refusal sentence
def test_paste_is_never_refused():-
Step 2: Run them to see them fail.
pytest tests/test_workspaces.py tests/test_flow_engine.py -v -k "controlled or refused or paste". Expected: FAIL. -
Step 3: Implement to the interfaces. In
_drive, theonError == "continue"test gainsand not isinstance(exc, faults.Controlled). -
Step 4: Run the suite.
pytest && ruff check .Expected: PASS. The lock is now held for every_said, so watchtests/test_locks.pyand the capture tests in particular. -
Step 5: Commit
feat: every surface is refused while a person holds the browser.
Files:
-
Modify:
kubed/selenium_flow/core/browser.py(ensure_url(driver, url, *, before_going: Callable[[], None] = lambda: None)) -
Modify:
kubed/selenium_flow/core/actions.py:-
Actions.control = None, besidecapture; -
_atpassesbefore_goingthat raisesself.control.refusal(session_id, "navigate"); -
screenshot'scaptureraisesrefusal(session_id, "resize")before awidth,heightorfull_pageresize.
-
-
Modify:
kubed/selenium_flow/server.py(self.actions.control = controls) -
Test:
tests/test_actions*.py(the file that drivesextractandscreenshotthroughScriptedDriver) -
Step 1: Write the failing tests (Review Focus 2):
def test_extract_on_the_current_url_answers_while_controlled():
def test_extract_naming_another_url_is_refused_before_it_navigates(): # driver.get never called
def test_a_screenshot_with_a_width_is_refused_while_controlled(): # set_window_size never called
def test_a_full_page_screenshot_is_refused_while_controlled():
def test_a_plain_screenshot_answers_while_controlled():-
Step 2: Run them to see them fail.
pytest tests/ -v -k "while_controlled". Expected: FAIL. -
Step 3: Implement to the signatures above.
refusalreturnsNonewhen nobody holds the browser, so the check costs one dict read. -
Step 4: Run.
pytest && ruff check .Expected: PASS. -
Step 5: Commit
feat: a read that would move the page or the window waits for the hand-back.
Files:
- Modify:
kubed/selenium_flow/monitor/monitor.py:-
sinks: Mapping[str, Sink]replacessink; -
Watch.heardandWatch.top; - routing in
_hold,_extend,_deliver,_loser,_detachedand_unowe; -
capturing()means capture's connection; -
async attach_here(...).
-
- Create:
kubed/selenium_flow/live/pages.py - Modify:
kubed/selenium_flow/live/control.py(_grantawaitsmonitor.attach_here(..., "control")first; the release callsmonitor.release(grid_id, "control")andpages.stop(grid_id)) - Modify:
kubed/selenium_flow/server.py:-
Pages()before the monitor; -
events_for={"capture": EVENTS, "control": pages_module.EVENTS}; -
sinks={"capture": capture, "control": pages}; -
pages.connection = monitor.connection.
-
- Modify:
tests/test_boundaries.py(live.pagesinNO_SELENIUM) - Test:
tests/test_monitor.py,tests/test_monitor_capture.py,tests/test_live_pages.py(new),tests/test_live_control.py
Interfaces:
- Produces:
# monitor/monitor.py
Monitor(bus, *, ..., sinks: Mapping[str, Sink] | None = None, ...) # `sink=` goes; callers updated
async def attach_here(self, workspace: str, session_id: str, reason: str, *, browser: str = "") -> None
# `attach` on the loop: `_attach` under asyncio.timeout(ATTACH_TIMEOUT); any failure is BidiUnavailable
def capturing(self, session_id: str) -> bool # held, and "capture" in the watch's heard reasons
# Routing: a reason's sink hears `attached` when the hold opens with it owed (`_hold`),
# or when `_extend` adds it (top = Watch.top, again=False); then `ingest` for the
# events in events_for[reason], `replayed`, and `lost`; and `detached` at the close,
# or at `_unowe(reason)` when the connection stays for another.
# live/pages.py
EVENTS = ("browsingContext.load", "browsingContext.historyUpdated", "browsingContext.contextCreated")
class Pages: # a monitor Sink
connection: Callable[[str], Connection | None]
def follow(self, grid_id: str) -> None # start a list; Controls calls it before attach_here
def stop(self, grid_id: str) -> list[dict] # the list, ended; [] if never followed
def listed(self, grid_id: str) -> tuple[list[dict], int] # (pages, dropped), oldest first- Step 1: Write the failing tests.
# test_monitor_capture.py
async def test_a_control_only_hold_never_reaches_capture():
# attach_here(..., "control"): capture's sink hears no attached/ingest/detached;
# monitor.capturing(sid) is False; Calls(capture=capture).wanted("w1") is False
async def test_control_added_to_a_capture_hold_subscribes_its_events_on_the_same_connection():
# one connection; session.subscribe sent once more with only browsingContext.load (contextCreated
# and historyUpdated are capture's already); a load event reaches pages, not capture
async def test_releasing_control_keeps_capture_held_and_tells_pages_detached():
async def test_releasing_control_alone_closes_the_connection_clean():
# test_live_pages.py
async def test_pages_in_order_with_titles(): # load, historyUpdated → [{url,title},…]
async def test_a_frames_load_is_not_a_page(): # context with a parent
async def test_a_new_tab_is_followed(): # contextCreated parent None, then load
async def test_a_title_that_does_not_come_back_is_left_out(): # script.evaluate times out
async def test_a_url_with_no_origin_records_nothing(): # about:blank, data:
async def test_at_most_a_hundred_pages_with_dropped():
# test_live_control.py
async def test_the_grant_waits_for_the_pages_to_be_followed():
async def test_bidi_unavailable_still_grants_with_followed_false():-
Step 2: Run them to see them fail.
pytest tests/test_monitor.py tests/test_monitor_capture.py tests/test_live_pages.py tests/test_live_control.py -v. Expected: FAIL. -
Step 3: Implement.
-
Pages.ingestappends onloadandhistoryUpdatedfor a top-level context. That isWatch.top, handed in throughattached, or one seen incontextCreatedwithparentnull. - The title is read in a task:
connection(grid_id).command("script.evaluate", {"expression": "document.title", "target": {"context": ctx}, "awaitPromise": False})underasyncio.timeout(REPLY_TIMEOUT), and filled into the entry it was appended for. - No URL or title is logged.
-
-
Step 4: Run.
pytest && ruff check .Expected: PASS. The capture tests are the regression net for the routing. -
Step 5: Commit
feat: the pages a person reaches in Control are followed on capture's connection.
Files:
- Modify:
kubed/selenium_flow/mcp/control.py(Notices(controls)) - Modify:
kubed/selenium_flow/server.py(control.Notices(self.controls)) - Test:
tests/test_control_notice.py
Interfaces:
-
Consumes:
Controls.notice(workspace) -> dict | None(Task 4). -
Step 1: Write the failing tests, through a real
fastmcp.Clienton the test server:
async def test_an_in_flight_call_carries_control_in_structured_content():
# the call's action asks control mid-call; result.structured_content["control"] ==
# {"state": "requested", "who": ["kelly"], "since": "…Z", "hint": <the requested notice>}
# and the text block ends with the same hint
async def test_a_read_while_held_carries_state_held():
async def test_the_unsaid_hand_back_is_said_once():
async def test_a_refusal_carries_no_second_notice(): # the error text has the refusal sentence once
async def test_a_create_task_result_passes_untouched(): # R1
async def test_a_screenshot_image_gains_structured_content_with_only_control():-
Step 2: Run them to see them fail.
pytest tests/test_control_notice.py -v. Expected: FAIL. -
Step 3: Implement.
- A
ToolResultgainsstructured_content["control"]. When there is none, it becomes{"control": …}. - A
ToolErrorwhose__cause__is afaults.Controlledis re-raised as it is. - Any other result type, such as
CreateTaskResult, is returned untouched.
- A
-
Step 4: Run.
pytest tests/test_control_notice.py tests/test_surfaces.py && ruff check .Expected: PASS. If a tool's output schema refusescontrolin a client's validation, declarecontrolincapabilities._page()besidepage. That moves the OpenAPI golden: regenerate and commit it on its own. -
Step 5: Commit
feat: an agent sees control in the structured result Claude Code reads.
Files:
- Modify:
kubed/selenium_flow/config.py(LiveSettings.await_cap: int = Field(90, ge=30, description="Seconds a blocking await_control waits before it answers \"call again\".")) - Modify:
kubed/selenium_flow/core/capabilities.py(theawait_controlrow:route="await-control",http_method="POST",annotations=reads("Wait for a person to hand back"),under_control="answers",waits=True,response=AWAIT_RESPONSE) - Modify:
kubed/selenium_flow/live/control.py(async def await_control) - Modify:
kubed/selenium_flow/workspace/workspaces.py(handed_back) - Create:
kubed/selenium_flow/mcp/waiting.py - Modify:
kubed/selenium_flow/mcp/tools.py(the async tool withtask=;publish(..., task=None)) - Modify:
kubed/selenium_flow/mcp/clients.py(CARRIED = "x-selenium-flow-workspace", read only in the no-request branch;Carrymiddleware) - Modify:
kubed/selenium_flow/routes.py(rows withwaitsget their own async handler) - Modify:
kubed/selenium_flow/flows/document.py(NOT_STEPS["await_control"]:A flow cannot wait for a person: a refused step stops the run, and whoever started it calls await_control) - Modify:
kubed/selenium_flow/server.py(mcp.add_extension(TasksExtension(url="memory://"));Carryadded beforeNotices) - Modify:
tests/test_surfaces.py(EXPECTEDgainsawait_control;test_every_route_maps_to_a_real_actionskips rows withwaits; read-only pinned in both modes;UNDER_CONTROLanswers gains it) - Test:
tests/test_await_control.py(new); goldens (tool lists, OpenAPI);docs/wiki page anddocs/Configuration.md
Interfaces:
- Consumes:
Controls.wait,Episode(Task 4),Pages.listed/stop(Task 7),Workspaces.browserandsettle. - Produces:
# live/control.py
@dataclass(frozen=True)
class Waited:
kind: Literal["handed_back", "still", "nobody"]
episode: Episode | None
async def await_control(controls: Controls, workspaces, name: str, *, cap: float | None) -> dict
# wait (cap=None: until the hand-back), then workspaces.handed_back in a thread (anyio)
# workspace/workspaces.py
def handed_back(self, name: str, episode: dict | None) -> dict
# page (url, title) and outline({}) through `_said` on `self.browser(name)`, never reopening;
# settle(url=None); the summary per the spec, `visited` shaped by urls (last whole minus userinfo,
# earlier page_of); `ended: true` with no page or outline when there is no browser
# mcp/waiting.py (R6)
async def waited(ctx, work: Callable[[float | None], Awaitable[T]], *, cap: float, message: Callable[[], str]) -> T
# cap is None in a background task (ctx.is_background_task); progress every progress.HEARTBEAT
# mcp/clients.py
class Carry(Middleware): # on_call_tool for a tool with task support: set CARRIED in the request's
# scope headers from caller().name before call_next (R3, ruling 31)- Step 1: Write the failing tests in
tests/test_await_control.py, through a realfastmcp.Clienton the test server and through the ASGI app:
async def test_it_returns_with_the_summary_the_moment_the_person_hands_back():
# legacy client; the call waits; controls.withdraw → result.structured_content has
# handed_back True, control.who ["kelly"], held_for, page, visited, outline
async def test_a_tasks_client_gets_a_task_and_the_summary_at_the_hand_back():
# import fastmcp_tasks; Client(mode="auto"); call_tool_task → status "working";
# hand back → result().structured_content["handed_back"] is True
async def test_the_workspace_reaches_the_worker_from_the_query(): # ?workspace=w1 only
async def test_the_blocking_call_sends_progress_and_answers_at_the_cap():
# cap 0.2 (patched); handed_back False; message "still in a person's control; call again."
async def test_nobody_in_control_answers_at_once():
async def test_an_unsaid_hand_back_is_returned_once(): # then the next result has no notice
async def test_a_browser_that_ended_is_said_not_reopened(): # grid.open never called
async def test_a_cancelled_wait_leaves_the_episode_holding(): # Review Focus 5
async def test_the_long_poll_answers_the_same_summary(): # POST /browser/await-control
async def test_the_long_poll_answers_at_the_cap():
def test_it_is_not_a_flow_step(): # save_flow refuses it
def test_the_cap_is_at_least_thirty(): # LIVE_AWAIT_CAP=10 → ConfigError-
Step 2: Run them to see them fail.
pytest tests/test_await_control.py -v. Expected: FAIL. -
Step 3: Implement to the interfaces.
- The tool is
@own("await_control", task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=1))), anasync def await_control(ctx: Context) -> dict. - Its docstring is prompt. It says what control is, that a refusal names this tool, and that it returns when the person hands back, with what they did. It says that on a client without background tasks it returns after about 90 seconds saying to call again.
- The route's handler authorises and names the caller as
_answerdoes, but awaits the body on the loop rather than in the threadpool.
- The tool is
-
Step 4: Regenerate and run.
Run: GOLDEN_UPDATE=1 pytest (read the diff under tests/golden/: one tool in both modes, one operation), then python scripts/generate_openapi.py && python scripts/generate_wiki.py && pytest && ruff check .
Expected: PASS. docs/await_control.md exists, and docs/Configuration.md gains live.await_cap.
- Step 5: Commit the code as
feat: await_control returns when a person hands the browser back, then the goldens on their own, then the wiki insidedocs/with the pointer bump.
Files:
-
Modify:
ui/src/admin/liveview.svelte.ts(LiveModegains'requested';#textaccepts it;viewOnlystays true untilcontrol) -
Modify:
ui/src/admin/LivePane.svelte(the toggle readswaiting for the agent's call to finishwhile requested; Paste stays disabled) -
Test:
ui/src/admin/liveview.test.ts,ui/src/admin/LivePane.test.ts -
Step 1: Write the failing tests.
it('stays view-only while control is requested', () => { /* text frame mode:requested → mode 'requested', rfb.viewOnly true */ })
it('says it is waiting for the agent', () => { /* LivePane shows "waiting for the agent's call to finish" */ })
it('grants on control', () => { /* requested then control → viewOnly false */ })-
Step 2: Run them to see them fail.
npm --prefix ui test. Expected: FAIL. -
Step 3: Implement.
-
Step 4: Run the UI checks.
npm --prefix ui ci && npm --prefix ui run lint && npm --prefix ui run check && npm --prefix ui test && npm --prefix ui run build. Expected: PASS. -
Step 5: Commit
feat: the live view says when Control waits for the agent's call.
Files:
-
Modify:
skills/selenium-flow/SKILL.md(a routing-table line: "a person took control, or a call was refused for it → references/WORKSPACES.md") -
Modify:
skills/selenium-flow/references/WORKSPACES.md(a "When a person takes control" section) -
Modify:
AGENTS.md:- "The live view" loses ruling 35's sentence on the notice and gains a short "A person in Control holds the browser" bullet list: the gate after the turn; the reads; the mark, not a thread; the
controlreason's own sink;await_controlas a task and as a capped poll; Docket in memory; - the "Scaling" paragraph gains control and Docket as process memory.
- "The live view" loses ruling 35's sentence on the notice and gains a short "A person in Control holds the browser" bullet list: the gate after the turn; the reads; the mark, not a thread; the
-
Create:
docs/notes/await_control.notes.md(the cap, Tasks versus blocking, the gateway) -
Modify:
CHANGELOG.md, under## [Unreleased]→Added:**A person who takes Control in the live view holds the browser**: agents are refused until they hand back, and \await_control` waits for it.` -
Test:
tests/test_skill*.py,tests/test_wiki.py,tests/test_readme.py -
Step 1: Write the section and the lines. Write for a model deciding what to do next: what a refusal looks like; call
await_control; what the summary holds; reads still answer. -
Step 2: Regenerate and run.
python scripts/generate_wiki.py && pytest tests/test_skill*.py tests/test_wiki.py tests/test_readme.py && ruff check .Expected: PASS. Every reference is linked, and no note shadows a page. -
Step 3: Commit
docs: control and await_control for agents and maintainers, then the notes insidedocs/with the pointer bump.
- Step 1: Run every check CI runs.
ruff check . && pytest
npm --prefix ui ci && npm --prefix ui run lint && npm --prefix ui run check && npm --prefix ui test && npm --prefix ui run build
python scripts/generate_openapi.py
pip-auditExpected: all pass, with CHANGELOG.md carrying the new [Unreleased] line.
- Step 2: Live, on the cluster (Dr K, after the image is deployed), against "Done when":
- Claude Code through agentgateway, with Tasks: a call in flight finishes with
controlshown, the next acting call is refused,await_controlruns in the background and returns at the hand-back with the pages; - the same with a client that has no Tasks, where the call returns at 90 s saying to call again;
- pages followed with capture on and with capture off;
- a shadow-DOM input keeps its caret, and the viewer gets 1012 when the server restarts with it open.
- Claude Code through agentgateway, with Tasks: a call in flight finishes with
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network