[Bug]: browser — daemon mode's MCP session dies right after startup, so fs cd never persists
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
- Windows 11, DOMShell server running and DOMSHELL_TOKEN / DOMSHELL_PORT set, Chrome tab open with the DOMShell extension Connected.
- cli-anything-browser --daemon
- fs pwd -> /
- fs cd tabpanel_1234 -> "Changed to: /tabpanel_1234"
- fs pwd -> / <-- the cd was lost
- 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):
- Own a process-wide event loop on a background thread.
- Enter AND leave
stdio_client+ClientSessioninside 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. - Marshal each
domshell_executeonto that loop withasyncio.run_coroutine_threadsafe(...)and await it viaasyncio.wrap_future. - 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:
fscannot read page text.catmaps to DOMShell'scat, which returns element metadata (role / AXID / child count) only, so there is no way to get the words off a page -ls+catalone can never answer "what does this page say". Addingfs text [path] [--limit N] [--links]fixes it. Note DOMShell's flag parser wants the value attached:text --limit=120works,text --limit 120is parsed as "read the element named 120".There is no way to re-attach the Chrome debugger in place. DOMShell attaches
chrome.debuggerlazily 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 openattaches but always adds a tab, andrefreshdoes not re-attach.page navigate <url>(DOMShell'snavigate) 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.
Source: HKUDS/CLI-Anything