[Bug]: browser — daemon mode's MCP session dies right after startup, so fs cd never persists

Author: tcgztopCreated Sep 13, 2026Updated Sep 13, 2026

Affected Software/Harness

browser

Version / Commit

cli-anything-browser 1.0.0 (repo commit 810c18b0d1ab9b234bc996c9fd999318523a3ef0, main) · @apireno/domshell 1.1.1 (server reports DOMShell v1.0.0)

Operating System

Windows

Python Version

3.12.10

Steps to Reproduce

  1. Windows 11, DOMShell server running and DOMSHELL_TOKEN / DOMSHELL_PORT set, Chrome tab open with the DOMShell extension Connected.
  2. cli-anything-browser --daemon
  3. fs pwd -> /
  4. fs cd tabpanel_1234 -> "Changed to: /tabpanel_1234"
  5. fs pwd -> / <-- the cd was lost
  6. fs cat -> resolves against the wrong cwd

Expected Behavior

With --daemon the harness holds ONE persistent MCP session, so DOMShell's per-session cwd survives between commands: after fs cd main, fs pwd reports /main and fs cat child reads the child of main. That is the whole point of daemon mode ("persistent MCP connection"), and what the README's daemon section advertises for REPL use.

Actual Behavior

The daemon session is dead before the first command uses it.

start_daemon() does asyncio.run(_start_daemon()) (domshell_backend.py:1193). That creates a session whose anyio task group and memory streams are bound to that temporary event loop; asyncio.run closes the loop as soon as it returns. The very next _call_execute therefore reaches a torn-down session:

RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
...
DOMShell daemon call failed, respawning per-command:
anyio.ClosedResourceError

The failure is swallowed by design: _call_execute logs a warning and falls back to spawning a fresh domshell-proxy per command (domshell_backend.py:571-579). Each spawned proxy is a NEW MCP session, and DOMShell keeps its cwd per session, so the fallback silently discards the working directory. Result: daemon mode costs the same as non-daemon mode and fs cd has no effect on the next command.

The author already documented this exact limitation in the source:

# NOTE: Known limitation - Daemon mode uses asyncio.run() per tool call (in sync wrappers).
# Each asyncio.run() creates a new event loop. Async IO objects created in one loop
# (like the daemon session) may have issues when accessed from subsequent calls that
# create new loops. This is a documented limitation for v1; future work should use
# a single long-lived event loop (e.g., background thread + run_coroutine_threadsafe).
#                                                      -- domshell_backend.py:608-612

Suggested fix (what that NOTE describes, implemented and verified locally):

  1. Own a process-wide event loop on a background thread.
  2. Enter AND leave stdio_client + ClientSession inside ONE long-lived host task on that loop. anyio's cancel scope must be exited by the task that entered it, so per-call tasks cannot open the session.
  3. Marshal each domshell_execute onto that loop with asyncio.run_coroutine_threadsafe(...) and await it via asyncio.wrap_future.
  4. On shutdown, signal the host task to leave its context instead of calling __aexit__ from a different task.

Measured before/after on the same machine and the same DOMShell server:

before:  fs cd tabpanel_388  ->  fs pwd  ->  ~/windows/<win>/<tab>            (unchanged)
         every command logged "respawning per-command" + ClosedResourceError
after:   fs cd tabpanel_388  ->  fs pwd  ->  ~/windows/<win>/<tab>/tabpanel_388
         no fallback warnings; one proxy spawn per CLI invocation instead of three

Relevant Logs / Tracebacks

an error occurred during closing of asynchronous generator <async_generator object stdio_client at 0x...>
asyncgen: <async_generator object stdio_client at 0x...>
  + Exception Group Traceback (most recent call last):
  |   File "...\anyio\_backends\_asyncio.py", line 799, in __aexit__
  |     raise BaseExceptionGroup(
  | BaseExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "...\mcp\client\stdio\__init__.py", line 189, in stdio_client
    |     yield read_stream, write_stream
    | GeneratorExit
    +------------------------------------
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
  File "...\mcp\client\stdio\__init__.py", line 183, in stdio_client
    anyio.create_task_group() as tg,
  File "...\anyio\_backends\_asyncio.py", line 805, in __aexit__
    if self.cancel_scope.__exit__(type(exc), exc, exc.__traceback__):
  File "...\anyio\_backends\_asyncio.py", line 455, in __exit__
    raise RuntimeError(
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
Daemon mode: persistent MCP connection active
DOMShell daemon call failed, respawning per-command:
Traceback (most recent call last):
  File "...\cli_anything\browser\\\utils\domshell_backend.py", line 555, in _call_execute
    result = await _daemon_session.call_tool(
  ...
anyio.ClosedResourceError

Additional Context

Two related gaps found while chasing this, both worth fixing in the same area but independent of the daemon bug - happy to send them as a separate PR:

  1. fs cannot read page text. cat maps to DOMShell's cat, which returns element metadata (role / AXID / child count) only, so there is no way to get the words off a page - ls + cat alone can never answer "what does this page say". Adding fs text [path] [--limit N] [--links] fixes it. Note DOMShell's flag parser wants the value attached: text --limit=120 works, text --limit 120 is parsed as "read the element named 120".

  2. There is no way to re-attach the Chrome debugger in place. DOMShell attaches chrome.debugger lazily and drops it between commands: cached-tree commands (fs ls, fs cat) keep working, but live ones (text) fail with "Debugger is not attached to the tab with id: ...". page open attaches but always adds a tab, and refresh does not re-attach. page navigate <url> (DOMShell's navigate) re-attaches the current tab in place.

With the daemon fix plus those two commands, driving a real site through the harness works end to end (verified against a live site: navigate -> ls -> cd into the tab -> fs text returned ~2900 characters of page text). The upstream unit suite stays green at 201 passed / 11 skipped with 6 new tests covering text and navigate.