test_sequential_tool_timeout_emits_result_and_continues asserted
first_started.is_set() immediately after the executor returned — but on a
starved CI runner the 0.05s tool timeout can expire before the hung call's
worker THREAD is even scheduled, and the timeout path's future.cancel()
then legitimately prevents that dispatch entirely. That is the exact
behavior under test (executor refuses to wait), not a failure.
The test now asserts the real contract: executor returns fast, the NEXT
tool actually dispatched, and the message stream carries both call ids in
order with the timeout result first. Verified both ways with a
thread-start-delay sabotage run: old assert fails under delay, new
assertions pass under delay and still fail if the executor stops
continuing past a hung call.
Consolidates the Capabilities view around the Skills tab and opens the
whole surface to plugins:
- Skills tab: the embedded Skills Hub picker now renders BELOW the
installed-skills list, expanded by default, with the update-all action
in its header. "+ Add to this Agent" picks are refused with a toast
when the skill is already installed in the scoped profile (name and
identifier both checked against the unfiltered list).
- Detail pane: shows the ENTIRE skill for any provenance — frontmatter
metadata rendered as key/value rows plus the full SKILL.md body —
via the existing GET /api/skills/content (new getSkillContent fetcher,
profile-scoped, cached per skill+scope).
- Browse Hub top-level tab removed (hub.tsx deleted, tabHub i18n keys
dropped); the hub lives inside Skills now. Legacy ?tab=hub URLs fall
back to Skills via useRouteEnumParam. Hub actions/store and all hub
REST fetchers are unchanged.
- SkillsView gains `embedded` (tab state local to the component instead
of the route ?tab= param) and `fixedProfile` (pins every tab to one
profile; the scope selector hides and the profiles roster fetch is
skipped) and is exported from @hermes/plugin-sdk — so Hermes-Bot-Mode
can render the real Capabilities surface inside its create/edit agent
Advanced section pinned to a bot.
- i18n: hub.alreadyInstalled (en + zh; others fall back).
Tests: index.test.tsx 8/8 (new: full-skill detail pane renders
frontmatter+body; picker refuses already-installed picks and is expanded
by default); toolset-config-panel 28/28; hermes-parity 11/11. Typecheck
(3 tsconfigs) + eslint clean.
Two live-QA findings from a locked KDE desktop (real cua-driver 0.20.0):
1. capture() with zero discovered windows returned a bare
'capture mode=ax 0x0' — no hint that the desktop session was LOCKED,
which freezes renderers and hides windows. New
_empty_discovery_reason() names the dominant causes in order: locked
session (loginctl LockedHint probe, fail-safe), missing DISPLAY,
else a pointer at hermes computer-use doctor. Surfaced through the
existing window_title -> summary path, so the model and the user see
it inline.
2. _call_tool_via_cli retried 'daemon is not running' 4x with ~3.5s of
backoff sleeps — a permanent condition for that invocation (the CLI
transport needs the machine-wide daemon; Hermes' MCP runtime does
not). Now fails fast on the first attempt with a message naming the
split. Transient empty output (EAGAIN congestion) keeps the retry
loop — pinned by test.
Live-verified on the locked desktop: capture now reports the lock and
the unlock action; CLI fallback errors immediately with the transport
explanation. 7 new tests; 191 sibling tests green.
lean+recovery 68.3% avg recall @ 49K retained vs current 45.8% @ 162K —
+22.5pts at 0.30x tokens. Anchor index moved GUI needle-fact recall
23.3->60.0 closed-book, 46.7->80.0 with recovery.
- _build_anchor_index(): regex-harvests PR/issue numbers, SHAs, branches,
file paths, error strings, handles, URLs from the compacted region into a
bounded indexed summary section. LLM-free, so needle identifiers cannot be
paraphrased away (the GUI-lineage failure class: 10/15 verbatim-or-nothing
golds). Doubles as session_search query-anchor map.
- evals/compaction/test_region_scoping.py: sentinel tripwire proving the
summarizer input carries ONLY the compacted region (head/tail sentinels
never reach the serialized turns body) in both legacy and lean modes.
- _digest_worthy() drops no-signal tool rows before chunking (GUI-lineage
digests were starving on tool-noise)
- eval recovery sim now uses in-memory SQLite FTS5 + BM25 (production
session_search engine) instead of term-frequency scoring
- recovery query writer sees the digest section (front of context) so it can
mine anchor identifiers
Measures recall accuracy vs tokens retained across compaction policies.
Real transcripts in, LLM-generated recall exam from the summarized region,
per-policy answer+judge passes, scorecard out.
Extends the Capabilities "Configuring:" profile selector (#86548) from
Tools/MCP to the WHOLE view — Skills, Tools, MCP, and Browse Hub now all
read and write the same selected profile — and brings Bot Mode's
one-click Skills Hub picker into the main Capabilities -> Skills tab.
Scope widening:
- skills/index.tsx: the selector renders once above whichever tab is
active. Skills list, toggles, bulk ops, editor, and archive are scoped
via the trailing-profile pattern; the skills RQ key gains the scope key.
Toolsets analytics (usage badges) load per scope. SkillsHub and the
hub picker are keyed/remounted per scope. Scope changes drop the open
editor/archive dialog and in-flight analytics (same hazards as an
app-wide profile switch); an app profile switch clears the override.
- hermes.ts: getSkills, setSkillEnabled, get/edit/deleteLearningNode,
getUsageAnalytics, and all seven skills-hub fetchers take the optional
trailing profile? (omitting preserves exact app-wide behavior).
- store/hub-actions.ts: runHubAction threads profile through spawn and
getActionStatus polling so install/uninstall/update and their logs run
against the scoped backend.
- hub.tsx: sources/search/preview queries keyed+scoped per profile;
install/uninstall/update/scan route to the scoped profile.
- archive-skill-confirm-dialog.tsx: optional profile prop.
One-click hub installs (from Hermes-Bot-Mode):
- skills/embedded-hub-picker.tsx: collapsible, resizable iframe of the
live Skills Hub (hermes-agent.nousresearch.com/docs/skills?embed=picker)
on the Skills tab. Origin-checked hermes-skill-pick postMessages route
through the standard hub action pipeline (background action, tailed
log, optimistic flip, Skills list + slash-completion invalidation),
scoped to the selected profile.
- i18n: skills.hub.picker* keys (en + zh; others fall back).
Tests: index.test.tsx — new case asserts picking a profile on the Skills
tab refetches skills scoped to it and routes toggles there (6/6);
toolset-config-panel 28/28. Full typecheck (3 tsconfigs) + eslint clean.
Regression test for the _ref_map merge (salvaged from #79515): the live
0.19.3 driver splits action refs into refs[] while content_refs re-lists
every node with empty actions; the empty entries must not clobber the
action-bearing ones. Caught live: every typed click refused with
browser_ref_stale until the merge fix.
cua-driver >= 0.17 splits the semantic_v2 snapshot payload: action-bearing
refs live in the `refs` array while `content_refs` carries every node
with EMPTY action lists. _ref_map only absorbed content_refs, so every
click/pointer/type ref was registered with no declared actions and all
typed-browser mutations failed with browser_ref_stale.
Merge refs + content_refs + snapshot.refs with set union so action info
is never dropped by an empty content entry. Verified against the 0.17
split format, the legacy refs-only format, the transitional dict format,
and the snapshot.refs fallback.
The Desktop's content-based truncation-target resolution (and reactions)
address persisted turns by row_id, but session.history loaded the
transcript without include_row_ids=True, so _history_to_messages had no
stamp to forward and the projection silently stripped the one durable
address clients can use. Discovered live-testing the #87294 client flow:
resolveDurableRowId saw 0 stamped rows and degraded every edit to a
plain resubmit.
Client half of #87059. The gateway now fails ordinal-only truncation
closed for durable sessions (#87150), which turned the mis-aimed cut into
a visible edit-resend error for any bubble without a bound rowId (edit
after an interrupted turn, unstamped resume). Make the Desktop always
produce a durable address or degrade safely:
- runRewindSubmit: when a truncation request lacks a durable address,
resolve the target's row id by exact content against session.history
(which ships row_id per persisted row). Resolution is
exact-or-nothing: a unique text match wins; ambiguity is accepted only
when the target is provably the newest persisted turn (the
edit-after-interrupt shape). Anything else degrades to a PLAIN
resubmit — never a guessed cut. The client ordinal is dropped either
way (its space can diverge from the gateway's — the #87059 root).
- planReload/planRestore: degrade failed turns to a plain resubmit
(extends the #86623 pattern to regenerate/restore) and carry the
turn's persisted sourceText as the content key.
- rebindSurvivorRowIds: iterate the same failed-turn-aware ordinal
space as the truncate math.
- session-tile-actions: reload goes through the shared runRewindSubmit
primitive instead of a raw prompt.submit, so the tile surface gets the
same discipline.
A user turn whose submit failed keeps its optimistic bubble but never
reached the gateway, so counting it makes every later
truncate_before_user_ordinal overshoot the backend index (refused 4018,
regenerate dead for the rest of the session). Skip failed turns in the
one shared visible-user ordinal space (visibleUserMessageIndices) used by
truncate ordinals, ordinal->index resolution, and survivor-rowId
rebinding.
Based on #41275 by @vondelomlo, relocated onto the split
use-prompt-actions/ modules and widened from visibleUserOrdinal to the
shared index helper.
Live-tested against the real cua-driver 0.19.3 binary (Linux x86_64):
- bounded serve flags corrected: the daemon accepts
--session-policy/--approve-session-policy, not the docs'
--capability-manifest names (which it rejects). Verified end-to-end:
a bounded daemon with a real policy file starts and reports running.
- browser-approve verified real but interactive-only (refuses without a
TTY) and its token is a legacy compatibility path disabled by default
on current drivers (per the live browser_prepare schema). Kept as a
passthrough; no longer presented as the primary route.
- NEW primary standard-mode route, verified live: launch the runtime
with cua-driver's trusted-launcher grant. config opt-in
computer_use.grant_existing_profile: true appends
--grant existing-profile to the standard-mode MCP spawn (MCP
initialize verified accepting the flag). Default false = attachment
keeps failing closed. Never applied to bounded/unrestricted daemons.
- Skill, system prompt, tool schema, and docs updated to the verified
ladder: config grant > bounded manifest > YOLO; token = legacy.
Completes the typed cua_browser_* route (PR #74166 lineage) with the
authorization surface that makes existing-profile attachment and
repeatable bounded automation reachable by real users:
- hermes computer-use browser-approve: CLI passthrough that mints
cua-driver's five-minute single-use attachment token for one exact
(pid, window_id). The user, never the model, is the token source.
- approval_token passthrough on cua_browser_prepare (schema + dispatch +
browser_route), forwarded only for existing_profile and only as a
non-empty string.
- computer_use.permission_mode: bounded + capability_manifest config:
private per-session embedded daemon launched with
--capability-manifest/--approve-capability-manifest; missing manifest
fails loudly. 'unrestricted' is deliberately NOT a config value —
it stays bound to the explicit per-session YOLO toggle.
- Skill + system-prompt + docs guidance for the three authorization
rungs and the isolated-profile-first default.
E2E-verified against a temp HERMES_HOME: real config resolution to
bounded, loud failure without a manifest, real argparse path driving a
fake cua-driver binary, standard default preserved.
The plugin's _Runtime.run_in_session wrapper serves every mark/event it
emits (turn start/end, approvals, subagent marks) and runs synchronously
on the agent's conversation thread. It passed no timeout, so the host's
run_in_session default (timeout=None) made each mark an UNBOUNDED native
call. With a wedged native Relay pipeline the agent blocked between API
calls with zero activity ticks — observed live 2026-08-15: two cron jobs
died at the 600s inactivity kill and a gateway chat session at 1800s,
all with last_activity="API call #N completed".
The core's scope push/pop/flush/close sites were bounded with
_SCOPE_OP_TIMEOUT after the 2026-08-10 delegation stall; the plugin's
event marks were the missed sibling class.
Changes:
- plugins/observability/nemo_relay: the wrapper always passes
timeout=relay_runtime._SCOPE_OP_TIMEOUT (10s) to the host. A breach
costs one telemetry span, never the agent; it also sets scope_errored
(so close_session skips the ATIF export for the wedged session) and
warns once so the sick pipeline is visible.
- tests/plugins/test_nemo_relay_bounded_marks.py: proves the budget
reaches the host (fails on the pre-fix code — sabotage-verified),
a TimeoutError flags the session and disables its export, and the
generic error path keeps its scope_errored contract.
Both quarantine wrappers (_run_quarantined_install in main.py and
_run_install_cmd in _install_repair.py) renamed live hermes*.exe shims
aside before invoking the installer, but only renamed them back on
FAILURE. A SUCCESSFUL install that never rewrites entry points — uv
audits an already-satisfied editable install as a no-op — left the
shims quarantined as hermes.exe.old.<ms> and `hermes` disappeared from
PATH after a green install (#75584; reproduced live on a Windows
install recovering from the #86735 self-lock deferral).
Switch both sites from except/re-raise to try/finally so restore runs
on every path. _restore_quarantined_exes already skips shims the
installer actually replaced, so fresh output is never clobbered and
failure behavior is unchanged.
Regression tests cover both wrappers x {no-op success, rewriting
success, failure}; the no-op cases fail on the previous code.
Session-finalize hooks ran synchronously on the gateway event loop from
three call sites (shutdown drain, session-expiry watcher, /new reset).
A plugin hook doing heavy blocking work froze the whole loop: adapter
heartbeats stopped, the drain machinery could not run, and systemd
eventually SIGKILLed the process mid-export. Observed live on a
multi-day 4.7G session where the nemo_relay observability plugin
serialized a full-session ATIF trace inside on_session_finalize.
Changes:
- gateway/run.py: new GatewayRunner._finalize_session_off_loop()
dispatches hermes_cli.lifecycle.finalize_session via the gateway
executor under asyncio.wait_for (10s budget), mirroring
_cleanup_agent_resources_off_loop (#53175). Shutdown finalize and
the session-expiry watcher now use it.
- gateway/slash_commands.py: /new reset path uses the same helper.
- plugins/observability/nemo_relay: ATIF export is now bounded
(HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S, default 30s) and skipped
entirely for sessions whose Relay scope operations already errored
(their exporter state is unreliable and the export can be
pathologically slow).
- tests/gateway/test_finalize_session_off_loop.py: regression tests
proving the loop stays live under a wedged hook and the budget is
enforced.
Each assistant reply now carries a small time badge below the message text
showing how long its turn took (message.start -> message.complete), so
users can gauge task latency at a glance without hovering.
The duration is computed renderer-side from the per-session turnStartedAt
timestamp the app already tracks and stamped onto the ChatMessage at
completion (successful and failed turns alike). It is not persisted
backend-side, so messages hydrated from history have no badge — matching
how reasoning-block durations already behave.
Also adds the assistant.thread.turnDuration i18n key across all five
locale files.
Follow-up hardening for the #74163 salvage: the no-payload settle gate used
turnStartedAt as "backend reported the turn live", but since the turn clock is
now optimistically seeded at submit (#86923), that signal is ambiguous.
Introduce ClientSessionState.turnLive, set on message.start, the running=true
session.info edge, and resume-onto-running paths; cleared by every settle.
The pre-start bail now gates on turnLive so a running=false heartbeat in the
submit gap still keeps the spinner up, while a genuinely started turn that
dies without a payload settles and unbricks the session.
A turn that finishes without ever producing an assistant payload never
reaches message.complete, so session.info with running=false is the only
event that can release it. The busy=false branch bailed out of the state
update whenever awaitingResponse was still set and no payload had been
seen, so awaitingResponse and busy stayed latched until the app was
restarted.
That is not a cosmetic indicator. The per-session busy flag is
authoritative for isTargetSessionBusy, so submitPrompt and the slash
dispatcher silently returned false: the user typed, pressed Enter, and
nothing happened, with no error. Per-session state does not self-heal on
a session switch, so the session was effectively bricked. It reproduces
on a gateway crash mid-stream, a provider error before the first delta,
and an agent-build failure.
The bail still has a real job: submit arms busy/awaitingResponse
optimistically, so a running=false heartbeat landing in the gap before
the turn spins up is a pre-start report, not a finished turn, and
settling on it would drop the spinner and re-open the send guard
mid-flight. Gate the bail on turnStartedAt, which is stamped only once
the backend reports the turn live and cleared by every settle: null means
no turn was ever reported running, so keep waiting; non-null means the
turn started and is now reported finished, so settle.
On recovery, catch up the surfaces the missing message.complete would
have refreshed. The sidebar refresh stays unscoped so a background
session's working dot clears without the user opening it, and it fires on
the recovery edge only because the unchanged-state guard short-circuits
every later heartbeat. The transcript hydrate is scoped to the active
session so an idle background session does not cost a REST call.
Commit 2ae7884ffa added _EXTENDED_ENTER_KEYS_SEQ which pushes both the
Kitty keyboard protocol (CSI >1u) and xterm modifyOtherKeys level 2
(CSI >4;2m) on supported terminals (Ghostty, iTerm2, WezTerm, kitty).
Under the Kitty keyboard protocol, Ctrl+C is encoded as \x1b[99;5u
(codepoint 99='c', modifier 5=Ctrl) instead of \x03 (ETX). prompt_toolkit
3.x has no mapping for \x1b[99;5u, so the sequence leaks as literal
text '[99;5u' on screen. Worse, the kernel's INTR mechanism looks for
the raw \x03 character, so SIGINT never fires either — Ctrl+C is
completely dead.
Fix: drop the CSI >1u push from _EXTENDED_ENTER_KEYS_SEQ, keeping only
modifyOtherKeys (CSI >4;2m). Shift+Enter still works via the
\x1b[27;2;13~ sequence that modifyOtherKeys produces and prompt_toolkit
already maps (Keys.ControlM). The exit reset sequence still pops both
modes for safety.
Refs #56684.
Follow-up on the cherry-picked gitlock work (#80501 by @RGerrish, covering
the #75133 / #75168 wedge first reported and fixed by @RelaxJonh):
- Drop the PR's ancestor-check halves in banner.py, update-count.ts and
main.ts: superseded by the compare-API status recovery that landed in
#86257/#86331 (ahead_by == 0 already reports local-ahead as up to date).
The salvaged update_cmd.py check path keeps main's compare-API structure
instead of the PR's tip-SHA-plus-ancestry print.
- Keep and wire clear_stale_git_locks() at the remaining wedge sites the
original PR targeted: hermes update apply, hermes update --check, and the
passive banner check.
- Add the desktop counterpart (electron/gitlock.ts) so checkUpdates() heals
the same wedge instead of reporting fetch-failed forever; mirrored
age + git-process guards; vitest coverage.
E2E verified: real --depth 1 clone with an aged .git/shallow.lock reproduces
"Unable to create '.git/shallow.lock': File exists"; clear_stale_git_locks
removes it and the fetch succeeds; a fresh lock (in-flight fetch) is
preserved.
Two related failure modes after a crashed/interrupted fetch on a shallow
clone (git clone --depth 1 installs):
1. STALE LOCK WEDGES EVERY FETCH. A killed fetch can leave .git/shallow.lock
behind; every later 'git fetch' then fails with 'Unable to create
.../shallow.lock: File exists'. 'hermes update --check' reported a hard
fetch failure, and the passive banner check swallowed the exception and
compared stale refs. Add hermes_cli.gitlock.clear_stale_git_locks(), a
guarded sweep (age + git-process check so a live fetch is never yanked)
wired into the check path, the apply path, and the banner's passive check.
2. SHALLOW TIP-SHA COMPARE FALSE-POSITIVES. On a shallow clone the check
cannot count commits, so it compares tip SHAs. Local cherry-picks on top
of the remote tip (e.g. re-applied local patches) make HEAD differ from
origin/main even though HEAD already contains it — a false 'update
available' banner. Add hermes_cli.gitlock.is_ancestor_of_head() and use
'git merge-base --is-ancestor' in the CLI check and banner paths before
reporting an update. Mirror in the desktop (update-count.ts gains an
isAncestor input; main.ts probes merge-base --is-ancestor).
Tests: tests/test_gitlock.py (9) covering stale/young/no-lock/no-repo sweeps
and ancestry true/false; update-count.test.ts +3 for the isAncestor path.
Follow-up to #86916. That fix gave named sessions their own daemon
(socket/log/pid) and their own provider browser — but on a SHARED local
Chrome / CDP browser, a fresh named daemon still attaches to the first
existing page, the same page a sibling daemon may hold. A named session
that never calls new_tab() could still stomp another's tab.
browser_exec now prepends a small preamble to the model's code for named
sessions on shared browsers: once per daemon process (marker keyed by
uid + BU_NAME + daemon pid), it creates a fresh tab via
Target.createTarget and switch_tab()s onto it before any model code
runs. Private per-name browsers (provider-keyed bu-named-<name>, or
direct-API Browser Use cloud) skip the preamble via an internal env
sentinel popped before launch — there's nobody to collide with, and the
extra tab would leak.
Best-effort by design: if the preamble's CDP calls fail, behavior
degrades to pre-fix, never blocks the exec.
E2E against a shared headless Chrome with the STOCK harness: two named
sessions issuing bare js() writes (no new_tab) kept distinct state
(EDGE-A/EDGE-B read back intact); the sabotage run without the preamble
reproduced the clobber (both read EDGE-B). Removes the dependency on the
upstream browser-harness tab-isolation PR for correctness.
The progress box's timer (turnStartedAt) was only seeded by the backend's
message.start event, so the submit RPC -> gateway accept -> WS round trip
(seconds under load) showed no timer at all. Seed the per-session clock in
seedOptimistic at Enter-time; message.start now keeps an existing seed
(?? Date.now()) so backend-originated turns still arm there, the active-
session mirror reuses the seeded value instead of snapping to accept-time,
and the abort/failure paths retire the seed with the turn. Adds a
console.debug submit->accept latency probe at message.start.
Desktop's send path pre-analyzed every attached image with the auxiliary
vision model serially, BEFORE dispatching the turn (_enrich_with_attached_
images). Users saw the progress box sit idle 25s-4min for messages that
take ~4s in the CLI; failures were silently swallowed, and touching
another session during the window killed the turn with zero API calls
(#83291). The prepended description also poisoned session auto-titles
(#82339).
Replace pre-analysis with _build_image_ref_message: reference the image
paths in the message and let the agent analyze them in-loop with
vision_analyze — its own retries, visible tool progress, and the turn
starts immediately. This is exactly how the @folder: reference path
already behaves, which responds in seconds for the same images.
Native-vision routing is unchanged; only the "text" mode (non-vision
main model / codex_app_server) loses the blocking submit-path calls.
Tests: tests/tui_gateway/test_image_ref_message.py (6 cases) including
a guard asserting the submit path never invokes the vision tool;
sabotage-verified (restoring the old blocking body fails 5/6).
_select_plugin_image_gen_provider hardcoded image_gen.use_gateway = False.
The managed (Nous-subscription) flow writes use_gateway = True via
_write_provider_config, then this selector runs AFTER it — so picking FAL
through Nous Portal silently persisted provider: fal, use_gateway: false
and every generation billed the user's personal FAL_KEY instead of the
subscription (real incident: key drained to zero-balance lock while the
managed route sat unused).
Fix the class, not the site:
- _select_plugin_image_gen_provider gains the same use_gateway kwarg its
video twin (_select_plugin_video_gen_provider) already had; all four
call sites pass use_gateway=bool(managed_feature), matching the video
call sites, TTS, STT, browser, and web.
- Active-provider detection (the checkmark in `hermes tools`): the
image_gen_plugin_name branch now defers managed entries to the
managed_feature branch and requires use_gateway OFF for direct-key
entries — mirroring the video branch's existing guard, so a managed
FAL pick and a direct-key FAL pick no longer both report active.
Runtime side (prefers_gateway("image_gen")) was already correct; the bug
was purely the setup-time writer.
Tests: new tests/hermes_cli/test_imagegen_managed_gateway.py (3 cases:
managed flag survives, direct pick still clears, image/video selector
contract parity). Sabotage-verified: restoring the hardcoded False fails
2/3. Neighboring hermes_cli provider/managed suites: 180 passed.
session=<name> previously set BU_NAME and then skipped backend resolution
entirely — the parameter was documented as cloud-only, so all local/CDP
work funneled through the single default daemon and one IPC socket, and
concurrent sessions (parallel subagents, simultaneous chats) clobbered
each other's browser connection. Reported by @shantanugoel on X.
Now a named session composes with whatever browser source is configured:
- BU_NAME still namespaces the harness daemon (per-name IPC socket, log,
pid — upstream already isolates these), for local Chrome and CDP.
- The /browser connect CDP override is now exported for named sessions
too; previously a named daemon ignored it and fell back to scanning
local Chrome profiles.
- On provider backends (Browserbase, Firecrawl, Nous gateway), the name
keys its own provider browser via the shared _get_session_info cache
(bu-named-<name>), so each name gets its own cloud browser, the same
name reuses one across calls and tasks, and unnamed calls keep the
per-task key.
- Direct-API Browser Use cloud configs keep the native named-daemon path
(provider resolution would double-session and double-bill).
Tool schema/description updated so models reach for session=<name> for
parallel work on any backend, not just cloud.
E2E: two named sessions against a real headless Chrome (real browser-use
CLI, BU_CDP_URL) ran concurrently, set distinct page state, and read it
back intact; sabotage run confirms the new tests fail without the fix.