A WS 'profile' param like '../../foo' normalized to a path component that
escaped the profiles root, letting a connected client bind an arbitrary
existing directory as a profile home (state.db opened there, and session
delete chains into per-id file cleanup under <dir>/sessions/).
get_profile_dir now validates the canonical name against the profile id
regex before joining it under profiles/. The regex only, not the reserved
list, so pre-reserved-list dirs like profiles/hermes keep resolving.
Callers that probe existence (profile_exists, _profile_home, the 4064
resolvers) treat ValueError as 'not found'.
Extends the multi-profile MCP paragraph with the identity rules landed in
this branch: OAuth tokens live per profile so each profile's calls run as
its own account; client_cert/client_key are part of the connection
identity; trust and supports_parallel_tool_calls are the consuming
profile's policy even when it shares another profile's connection.
Wording of the per-profile OAuth account guarantee follows the docs draft
in PR #109574 (its token-file fingerprint code path was not taken).
Co-authored-by: ly6751 <99090550+ly6751@users.noreply.github.com>
_parallel_safe_servers was keyed by the raw server name while _servers
moved to (scope, name) connection keys (ceaf622c6d). With profile A's `x`
serial and profile B's same-named `x` opted into parallel calls, B's
discovery pass flipped A's tool to parallel-safe and the batch planner put
two A calls in one parallel segment against a server that never opted in
(review of #108352, finding B).
The opt-in is now recorded under the discovering profile's own key and
is_mcp_tool_parallel_safe() looks it up under the calling profile's key,
so one profile's policy never reaches another's same-named server.
Single-profile processes keep the bare-name key, byte for byte.
Under gateway.multiplex_profiles a profile whose mcp_servers entry has the
same route and credentials as another profile's live connection adopts that
connection instead of opening its own. _same_server_route() compares only
the connection identity, so a `trust: untrusted` profile adopted a
`trust: full` profile's connection; _trust_gate_check() then resolved the
OWNER's connection key, read `full`, and let the untrusted profile run
write-capable tools without the approval prompt its config demands
(review of #108352, finding A; regression from ceaf622c6d, where the
name-keyed ledger let the last registrant's trust win instead).
`trust` is the consuming profile's policy, not a property of the
connection: _server_trust_levels is now keyed by the calling profile's own
key (recorded at its own registration and at adoption, dropped when its
overlay is removed), while readOnlyHint stays under the connection key
because it describes the server's tools. Sharing the connection is still
allowed — only the gate is per profile.
Follow-up to the #109930 salvage (#109901). The probe endpoint was the reported
site, but the same class covers every router path that expands a secondary
profile's `${VAR}` refs while only a home override is installed:
`GET /api/mcp/servers` (a `${VAR}` in `url` expanded from this process's env)
and the `/auth` config read, whose expanded entry is handed to the OAuth worker.
Hoist the PR's inline wrapper into one `_profile_secret_scope` context manager
(mirrors `_run_dashboard_mcp_oauth`'s wrapping) and use it at all three sites.
Policy unchanged: scope miss still falls through to os.environ outside
multiplexing; under multiplexing a miss is a miss, never another profile's value.
Tests: the salvaged probe test now uses monkeypatch.setenv (no raw os.environ
mutation); one invariant test for the list endpoint, red on origin/main.
The /api/mcp/servers/{name}/test endpoint reads config and probes with no
profile secret scope installed, so config.yaml's ${VAR} expansion
(_env_ref_lookup) and the probe's interpolation resolve against the
dashboard process's own os.environ — the default profile's values (or
nothing) on a shared remote dashboard. A secondary profile whose
credential comes only from an external secret source (Bitwarden/
1Password) never resolves and the probe sends the literal placeholder,
so the server answers 400 while a fresh profile-scoped CLI process
works (#109901).
Wrap both the config read and the probe in _config_profile_scope +
hydrate_profile_secret_sources + set_secret_scope so refs resolve
against the requested profile's .env plus its per-home hydrated secret
sources, matching the multiplexed turn path (#84079 semantics).
`write_runtime_status` re-stamps the previous writer's `gateway_state.json` in place and
only `_record_served_profiles` (multiplex on) ever wrote `served_profiles`, so a
multiplexer's list survived into a later non-multiplex run of the same home. Every
`hermes -p X` surface then kept treating X as served by that live default gateway: exit
78 on start/install, "running via the default-profile multiplexer" on status (review of
#108352, finding D, second half). The secondary-profile phase now writes an empty list
when multiplexing is off; an empty list is the authoritative "serves nobody else" the
readers already honour.
Both default-profile process matchers (`gateway.status._command_line_belongs_to_profile`
and `hermes_cli.gateway._scan_gateway_pids._matches_current_profile`) rejected a named
gateway with a substring test for `--profile ` / ` -p `, which the equals spelling the
CLI pre-parser accepts (`--profile=ops`) slipped past. The default home's identity check
then adopted that gateway's PID, and a default-profile `gateway stop` with no pid file
scanned the process table and could SIGTERM the named gateway (review of #108352,
finding E). Both sites now ask `profile_flag_value()`, the same tokenizer the named
branch already uses.
`live_default_gateway_pid()` trusted `gateway.pid` + `_pid_exists`, so a stale
default record whose PID an unrelated process had recycled kept its old
`served_profiles` authoritative: `hermes -p X gateway start` exited 78 and
`status` said "running via multiplexer" for a gateway long gone (review of
#108352, finding D). The salvaged #110167 fallback inherited the same bare
check for the pid-file branch.
One helper now answers "which live gateway owns this home?" for every reader:
`gateway.status.live_gateway_pid_for_home` = scoped `get_running_pid` (pid file
+ runtime lock, start-time reuse guard, live gateway command line, home match)
then `get_runtime_status_running_pid(..., expected_home=home)` (honours
`gateway_state` stopped/startup_failed). `gateway_multiplex_served`,
`gateway_migrate._live_gateway_pid` and the `hermes update` inventory's
gateway_state.json fallback (#109680: a `stopped` record + recycled PID
fabricated a phantom runtime, so the update exited partial) all route through
it. Tests that impersonated a gateway with this pytest PID now wear a gateway
command line instead of stubbing `_pid_exists`.
`live_default_gateway_pid()` (hermes_cli/gateway_multiplex_served.py) read only the
pid record, so it returned None for a gateway that is alive but has no gateway.pid.
Consumers of the helper then reported the gateway as down:
- `hermes -p <profile> cron list` printed "Gateway is not running" with "jobs won't
fire automatically" while the multiplexer was firing that profile's jobs
- `hermes -p <profile> status` dropped its "running (via the default-profile
multiplexer)" line
- `named_profile_served_by_running_multiplexer()` returned False for a profile the
live gateway serves
The rest of the liveness surface already handles a missing pid file: the
`runtime_pid_probe` seam of `resolve_gateway_liveness()` exists for "launch-service
gateways with no live PID file" (hermes_cli/profiles.py, hermes_cli/web_routers/),
and `hermes_cli/gateway_migrate._live_gateway_pid()` reads "pid file, then runtime
status". This probe was the one call site that never got either.
Read the pid record first, then the PID in `gateway_state.json` validated against the
process table, matching `_live_gateway_pid()`. A record naming a dead pid still
resolves to None, so a stopped gateway keeps reporting stopped and cron keeps warning.
Related to #99631.
`main` is a valid profile name (only hermes/default/test/tmp/root/sudo are
reserved), but _session_key_namespace mapped it to `agent:main` — the default
profile's namespace. Both profiles then built byte-identical keys: one routing
entry, one cached agent, and, since 75ae2859b9 pinned default-namespace
keys to the launch store, profiles/main's scoped sessions were written into
the ROOT state.db instead of profiles/main/state.db.
Key the `main` profile as `agent:main~` (`~` is outside the profile-id
alphabet, so the marked form cannot be any other profile's id) and give the
namespace slot one inverse, profile_from_session_key_namespace, used by the
store's key parser, _parse_session_key, the update-marker profile reader and
the profile-delete eviction prefix. Default keys stay byte-identical.
Every TUI session poller drains the one process-wide completion queue, but
e7136f1694 resolves compression lineage only in the dequeuing session's own
profile store. When profile B dequeued an event keyed on profile A's
compressed parent (A's original tab gone, its continuation live), B could
resolve nothing: belongs_elsewhere and owns were both false and
_notif_handle_event dropped the event permanently. Before returning "unowned",
ask the live sessions on other profile stores whether one of them provably
owns the event through its own lineage; if so it belongs elsewhere and is
requeued for that poller.
e7136f1694 made the side agent persist into the parent's dedicated profile
store by handing it the parent's registry-held SessionDB object, without a
reference of its own. The parent releases that reference from AIAgent.close()
or a session reset; when it was the last holder the registry tore the
connection down under the still-running background turn and later bg_* writes
hit a closed handle (the #94736 emergency reopen at best). Acquire a separate
reference on the same file for the turn — the shape tools/delegate_tool
already uses for delegated children — and release it when the turn ends.
hermes profile delete calls hermes_state_registry.close_all_under(profile_dir)
before rmtree, which force-closes the shared handle goals.py cached for that
home. A same-name recreate in the long-lived dashboard process then reused the
closed object: save_goal swallowed the closed-db error and the replacement
state.db was never created. Drop the cache entry once the registry has torn
the handle down (it clears _shared_registry_owned at teardown) so the next
call acquires a live generation for the recreated profile.
Under env → YAML → default an explicit env CSV beats the YAML list; the test now
blanks the env (blank env = unset) before asserting that [] is a real 'no chats'
value.
Rebase reconciliation with main's JSON-allowlist decoding (#109423): the two
remaining readers that consulted config.extra before the env var now follow the
per-profile precedence rule (explicit scoped env → the profile's YAML → default),
and ignored_threads still decodes a JSON-string list after the read.
The Matrix blank-YAML test asserted YAML-over-env, the old precedence #108440's
review flagged; it now pins the contract: explicit env beats YAML, a blank env
value is unset (YAML applies), YAML beats the default, and an explicit empty
list is a real "no rooms" value.
#109036 added six TELEGRAM_REACTIONS cases and #110111 five recovery-key-path
cases; keep the two contracts per fix (explicit env beats YAML; a scoped miss
returns the default) and drop the change-detector permutations.
Real loader + real adapter constructors under _profile_runtime_scope: a
secondary reads its own YAML lists/flags and never the launch env on a miss;
explicit env beats YAML for the owning profile; the central allow_bots gate
agrees with the adapter; Matrix YAML lists gate intake and approval; Yuanbao
home channel is live and reloadable; the WhatsApp bridge env carries the
secondary's policy. All eight cases red on origin/main.
_bridge_env copied os.environ (the default profile's WHATSAPP_* values under
multiplex) and only overlaid scoped hits, so a secondary with YAML
`dm_policy: pairing` launched its Node bridge under the default profile's
`allowlist` policy and the bridge rejected valid pairing DMs before Python saw
them. The child env now carries the values the adapter resolved (scoped env →
own YAML → default); a scoped miss removes the key rather than inheriting it.
Refs #108440 (ehz0ah inline, plugins/platforms/whatsapp/adapter.py)
For a multiplexed secondary the middleware wrote a top-level
YUANBAO_HOME_CHANNEL key that load_gateway_config never reads and skipped the
(correctly suppressed) process-env write, so cron and home-channel delivery had
no target in-process and none after a reload either. Persist through the
gateway's persist_home_channel (the profile-aware config path every /sethome
uses) and set the live PlatformConfig.home_channel; the process env is still
untouched under a secondary's scope.
Refs #108440 (ehz0ah inline, gateway/platforms/yuanbao.py)
GatewayAuthorizationMixin._chat_scoped_grant read only the scoped
{PLATFORM}_ALLOW_BOTS env var, so a secondary whose config.yaml said
`allow_bots: all` was admitted by its own adapter and then denied centrally
(Discord, Slack Workflow posts with user=None, Feishu, Telegram). The gate now
resolves the routed adapter's effective policy with the same reader as intake:
scoped env → adapter YAML → none. Mention requirement and loop guard are
unchanged.
Refs #108440 (finding 7)
545e74d0ea correctly stopped writing telegram.proxy_url into TELEGRAM_PROXY
for a multiplexed secondary, but _build_ptb_requests still resolved the proxy
only from that env var, so the secondary silently connected direct (or via the
default's proxy). #100448 had deliberately left this bridge unscoped for that
reason; this finishes the consumer migration instead.
_apply_yaml_config seeds proxy_url into extra and resolve_proxy_url gains a
`configured` rung: scoped TELEGRAM_PROXY → the profile's YAML → HTTPS_PROXY/
HTTP_PROXY/ALL_PROXY (trust_env) → macOS system proxy, with NO_PROXY semantics
unchanged.
Refs #108440 (finding 6)
One reader (gateway.platforms._shared.extra_or_secret) now implements the
precedence every per-profile setting follows for the OWNING profile:
explicit scoped env/.env → that profile's config.yaml (PlatformConfig.extra)
→ the adapter's default. A scoped miss returns the default, never the launch
process's os.environ; single-profile / default-profile installs keep the
documented env-over-YAML contract.
Why: 545e74d0ea (#108705) stopped bridging a secondary's YAML into the
process env and moved readers to config.extra, but the shared reader and the
hand-rolled helpers in Discord/Slack/Matrix/Telegram consulted YAML FIRST and
then fell back to a scoped env read. Two bug classes followed (#108440
post-merge review by andrexibiza, #109032):
- an explicit env value could no longer beat YAML for the owning profile
(DISCORD_ALLOW_MENTION_EVERYONE=false lost to allow_mentions.everyone: true;
TELEGRAM_REACTIONS=true lost to the stock reactions: false);
- a secondary that OMITTED a key inherited the launch profile's bridged env
through the fallback (Matrix process_notices/session_scope, Discord
auto_thread/reactions/mentions, Slack reactions/ignored_channels).
Consumers migrated to the shared reader: Discord _build_allowed_mentions and
_extra_or_env_flag; Slack _slack_allow_bots, _reactions_enabled (the
_extra_or_env_* getters already used it); Matrix _extra_truthy, _extra_csv_set,
session_scope, reactions, require_mention parsers, and — new — the
allowed_users / ignore_user_patterns consumers that never read the seeded YAML
lists; Telegram _extra_bool, _extra_str_set, _reactions_enabled; Feishu
allow_bots; WhatsApp dm_policy/group_policy.
Refs #108440, #109032
A2A_PORT and A2A_ADVERTISED_TOOLSETS are already captured at
construction time (inside _profile_runtime_scope) via
_get_scoped_secret(), but A2A_PUBLIC_URL was still read with a bare
os.getenv() inside A2ARequestHandler._request_public_url() - which
runs on ThreadingHTTPServer's per-connection OS thread, not the
constructing thread.
Raw threading.Thread never inherits contextvars, so even swapping the
reader to _get_scoped_secret() at that call site would not help: the
request thread has no scope, secret_scope falls back to os.environ
either way. The value must be captured once at construction time
(which does run in profile scope) and threaded through as instance
state instead - same fix shape as A2A_PORT above.
A secondary multiplex profile without its own A2A_PUBLIC_URL now
falls back to the X-Forwarded-Host/Host-derived URL (or the bind
host) instead of silently advertising the default profile's public
URL in its Agent Card / discovery response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 0c36aca5de53d88bbbc0b4cfceaed8307c744f7a)
Every other WEIXIN_* tunable in this __init__ block (dm_policy,
group_policy, rate_limit_circuit_*, send_chunk_*) already reads
extra-first with a scoped-secret fallback via _extra_or_secret(). This
one field was missed and still fell back to a bare os.getenv(), so a
secondary profile without its own split_multiline_messages setting
silently inherited the default profile's process-env value instead of
the coded default.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 44e3c0d5cf276a4ef78e676f2631fe89b11b5893)
#69090 scoped MATRIX_RECOVERY_KEY itself (via _scoped_recovery_key())
so a secondary profile resolves its own recovery key under multiplex,
but left its sibling, MATRIX_RECOVERY_KEY_OUTPUT_FILE, on a bare
os.getenv(). _recovery_key_output_path() is called from inside
_verify_or_bootstrap_cross_signing(), which runs fully inside
_profile_runtime_scope for a secondary profile: when that profile
bootstraps a new recovery key, it either doesn't get written to a
file at all, or gets written to the default profile's configured
path, depending on which one has the env var set.
Route it through the same _get_scoped_secret() helper _scoped_recovery_key()
already uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit fb765ee49b2f1a1853e52fb901d25f767180a2fc)
545e74d0ea made _reactions_enabled consult extra.reactions before the env
var, and _apply_yaml_config seeds extra["reactions"] whenever the YAML key
is present — including the stock reactions: false every install
materializes. The documented TELEGRAM_REACTIONS=true switch therefore
became a silent no-op after the 0.21.2 update (#109032), contradicting
yaml_env_setter's "explicit env wins over YAML" contract.
Read the scoped env first and fall back to the profile's own YAML: under
multiplex a scoped miss returns the default instead of another profile's
process-env value (#72348), so only a scoped/env hit counts as explicit
and per-profile isolation is unchanged.
Fixes#109032
(cherry picked from commit 2bd5a0a5c0a5f9630fd82f133def65f225f653a3)
The raw-output watcher modes (all/result/error) and the interim running
update sent the bracketed debug wrapper with the internal process id
(`[Background process proc_… finished with exit code N~ Here's the final
output: …]`) to Telegram/Discord/Slack chats. Reuse the concise one-line
status header for every mode and append the bounded, ANSI-stripped output
tail in a code block; the running update gets the same shape.
Salvaged from #54266 (rebased onto the post-#102117 run_notifications
sibling; the concise mode had landed in between, so the header is shared
rather than reimplemented). Also covers #13122 (ANSI stripping).
Reshape the two salvaged commits onto current main (#109954):
- Move the boundary guard out of the gateway_migrate facade into a new sibling
hermes_cli/gateway_migrate_guards.py as a table of guard functions
(_AUTO_MIGRATION_GUARDS: service domain, UNIX user, HERMES_HOME tree) plus the
identity resolver. The facade grows by ~20 lines only (uid/runtime_home on
ProfileGateway, one seam, the hook wiring).
- Compare uids, not strings: live pid owner via /proc (ps fallback only on
macOS, where /proc does not exist), else the system unit's User= via
_read_systemd_user_from_unit (root when absent), else the home directory's
owner. None means unknown and never blocks.
- The home-tree guard reads the HERMES_HOME the installed unit pins, not the
directory the plan enumerated: that is where the gateway really runs and is
exactly the "stale copies under profiles/" shape from the report.
- When the default is detached, a service-managed secondary is a different
domain for the AUTO path (it must not elect the secondary's manager); the
explicit command keeps electing it as before.
- The explicit command surfaces the same findings as notices (dry run shows
them) and is never blocked by them; only the update hook refuses.
- Rename the opt-out key to gateway.auto_multiplex_migration (nested only, no
top-level alias) and read it before a plan is built, so false prints nothing
and touches nothing. The explicit command ignores it.
- Tests trimmed to the invariants: one parametrized boundary test that exercises
the real hook end to end (refuses, touches nothing, dry run shows the notice),
one "same user / same scope still migrates" control, one opt-out test.
- Docs: boundary table + renamed opt-out section in multi-profile-gateways.md;
one line in hermes_cli/AGENTS.md.
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: Athena <athena@olympus.local>
`hermes update` folds an eligible multi-profile install onto one multiplexed
gateway on its own, and there is currently no way to say no. The only lever,
`gateway.multiplex_profiles: false`, is also the default: `_read_multiplex_flag`
returns `False` for "absent" and for an explicit `false` alike, so an operator
who has already decided to stay on per-profile gateways has no way to record
that decision. The migration runs again on the next update.
Add `gateway.auto_migrate` (bool, default `true`). Read from the default
profile's config, it gates the automatic path only:
- absent or `true` -> today's behaviour exactly, no change
- `false` -> `maybe_auto_migrate_after_update()` returns before
building a plan; no output, no changes
`hermes gateway migrate --multiplex` is an explicit request and still migrates
regardless of the flag, so it stays the supported way to opt back in.
One early return, one schema entry with the reasoning inline, one invariant
test (opt-out blocks the hook, absent/true do not, explicit command still
applies), one section in the multi-profile gateways guide.
Adding grep/awk/sed to _FILE_READ_COMMANDS made the PATTERN operand
participate in the secret-file predicate, so `grep .bashrc app.py` or
`grep -n .env src/settings.py` — reads of SOURCE files — ran the
ENV/YAML assignment pass and masked opaque values that main leaves
alone. Skip the first non-flag positional for the pattern-first
readers, as #109369 originally did, so only real file operands gate.
`cat $HOME/.hermes/config.yaml` was ungated because the `$` bail-out
fired before the `.hermes` segment was inspected; strip `$HOME/` and
`${HOME}/` like the HERMES_HOME prefixes.
Review follow-up on #110228.
Fold KoNit-K's `_command_reads_secret_bearing_file` and the pre-existing
`_command_reads_env_file` into a single `_command_reads_secret_file` so the
`code_file` gate in `redact_terminal_output` has one owner: `.env`-style
basenames and shell rc/profile files anywhere, `config.yaml` only under a
`.hermes` directory or `$HERMES_HOME` (arbitrary project YAML stays on the
code_file path). `grep`/`awk`/`sed` join the reader set instead of a second
table with a positional-argument special case: on a file read, any non-flag
operand that names a secret-bearing file is enough — the pattern/program
operand never matches a basename, so the extra rule bought nothing.
Tests: the negative parametrization now uses an opaque credential-shaped value
(the placeholder it used before would never have been masked on either path,
so the "stays unredacted" half proved nothing) and asserts the same value IS
masked under `cat .env` in the same test.
Keep the narrow basename allowlist, but do not treat $HERMES_HOME as an
unresolved path, and split pipelines only on unquoted |;&.
Co-authored-by: Cursor <cursoragent@cursor.com>
The text-only drop guard in _finish_chat_stream required content_parts,
so a stream that died while still emitting delta.reasoning (no
finish_reason, no usage) fell through to the synthesized "stop". With
the reasoning-only clean-stop promotion in finish_text_response that
stamped "stop" turned the truncated thought into the final answer,
where main entered the continuation ladder. Extend the guard with
reasoning_parts so the drop yields the partial-stream stub and the
ladder still runs; a real clean stop carries finish_reason="stop" and
is unaffected.
Review follow-up on #110227.
Follow-up to KoNit-K's commit: rebuild the promotion on the existing
`agent._extract_reasoning` helper (the same reader the ladder terminal and
`build_assistant_message` use) and write the promoted text back onto
`assistant_message.content` so the persisted assistant row carries the answer
as ordinary content. Without that the transcript tail was an assistant row with
empty content and only `reasoning`, which `drop_thinking_only_and_merge_users`
strips from the next request — the model would see its own answer vanish on a
"continue" turn.
Tests: trim to the two invariants (clean stop → one API call, persisted as
content; `finish_reason == "length"` → never promoted, continuation still
owns it) and keep the truly-empty terminal case. The prefill wire-payload
regression test now drives a non-clean-stop reasoning-only reply, which is the
only shape that still reaches the prefill rung.
check_vision_requirements only asked the auxiliary resolver, so a vision-capable
main model on a provider the resolver cannot serve (minimax-oauth, local vLLM,
anything uncatalogued) lost vision_analyze and browser_vision from the tool list
even though both handlers already route to the native fast path and work when
called. The image gate now accepts the native fast path OR an aux client; the
aux-only probe becomes check_video_requirements and stays on video_analyze,
whose handler has no native path.
Fixes#47149.
Both stale-pin detections exempt only '' and 'auto':
- desktop persistentStaleAux banner (model-settings.tsx)
- switch-time stale_aux response (hermes_cli/web_server.py)
'main' is a backend-supported alias (auxiliary_client._normalize_aux_provider)
meaning "follow the active main provider", so aux slots pinned to it can
never be stale. The false positive fires for users following Moonshot's
official Hermes integration guide, which prescribes
auxiliary.vision.provider: main.
Exempt the alias in both places and add a regression test.
`_do_full_reload` calls `shutdown_mcp_servers()` unscoped, which tears down
every profile's servers, but `discover_mcp_tools()` ran only under the launch
home. The all-sessions refresh then rebuilt a secondary-profile session's tool
snapshot under its own scope against a registry whose overlay was deregistered
and never rediscovered, so that session lost its MCP tools until its own
reload (main at least left its stale snapshot intact). After the pool rebuild,
rediscover once per distinct live `profile_home` under that profile's runtime
scope before refreshing the sessions.
The MCP pool is process-global but each agent snapshots `agent.tools` at build
time, so `/reload-mcp` from session A left session B's agent on the old tool
list until `/new` (losing its history); a request without a resolvable
`session_id` (desktop sends `activeSessionId ?? undefined`) refreshed zero agents
while still answering `reloaded`. After the pool rebuild, iterate every session
with a built agent under its own profile scope and push `session.info` to each.
Slim redo of PR #109383 by @nikkoxgonzales: the fan-out only, without the
mid-turn deferral, per-profile rediscovery loop and compute-host forwarding
changes that PR bundled.
Co-authored-by: nikkoxgonzales <nikkoxgonzales@gmail.com>
Under turn isolation `session.status` passed agent=None and only the metadata
mirror's model/provider, so until the compute host sent its first frame the
mirror was empty and the TUI rendered "Model: (unknown) (unknown)" where main
showed the in-process agent's route. Fall back to the live agent's model and
provider like `server._session_info` already does.
`get_recent_session_model_route` ordered by `last_seen DESC` alone; two rows
stamped in the same flush tie and SQLite's temp-sort order is unspecified, so
the retired route could be reported as current. Order by `rowid DESC` as the
secondary key so the route that appeared later wins.