# NousResearch/hermes-agent · Stale editable finder refresh during update

## Open now

- Nothing open on GitHub.

A bug where hermes updates leave the venv editable finder stale, causing ModuleNotFoundError crashes, along with a fix that refreshes the finder before skipping reinstall. Related tests for linger and schtasks are also addressed.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #120127. Verify it addresses your case.
- **Recommended:** read PR #120127 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-120127`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #119466: \[Bug\] hermes update can leave the venv editable-finder stale — gateway crash-loops with ModuleNotFoundError: No module named 'hermes_platform'

## Prior & competing PRs

- **#120127: hermes update: in-gateway cron updates no longer exit STALE, stale editable finder is refreshed, linger/schtasks tests fixed (#119597, #119466, #119786, #119845; salvage #119525 #119790 #119847)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/agent_init.py`, `agent/relay_runtime.py`, `agent/turn_iteration_prep.py`, `agent/turn_overflow.py`, `agent/turn_preflight.py`, `agent/turn_tool_round.py`, `apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts`, `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx`, `apps/desktop/src/app/session/hooks/use-session-actions/index.ts`, `apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.test.ts`, `apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.ts`, `apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx`, `apps/desktop/src/app/session/hooks/use-session-state-cache.ts`, `apps/desktop/src/store/gateway-connection-lifecycle.test.ts`, `apps/desktop/src/store/gateway.ts`, `apps/desktop/src/store/session-states.test.ts`, `apps/desktop/src/store/session-states.ts`, `contributors/emails/[redacted:email]`, `gateway/AGENTS.md`, `gateway/config_env.py` (+68 more)
  - fix hunks:
  - `agent/agent_init.py`

    ```diff
    @@ -1256,7 +1256,7 @@ def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]:
         return kwargs
     
     
    -def _init_memory(agent, _agent_cfg, skip_memory, platform):
    +def _init_memory(agent, _agent_cfg, skip_memory, platform, memory_manager=None):
         # Persistent memory (MEMORY.md + USER.md) — loaded from disk
         agent._memory_store = None
         agent._memory_enabled = False
    ```

    ```diff
    @@ -1297,7 +1297,12 @@ def _init_memory(agent, _agent_cfg, skip_memory, platform):
     
         # External memory provider plugin (one at a time, alongside built-in): memory.provider.
         agent._memory_manager = None
    -    if not skip_memory:
    +    if memory_manager is not None and not skip_memory:
    +        # A caller that rebuilds the agent per turn (gateway api_server) hands back the session's
    +        # already-initialized manager: providers keep their prefetch/retain state across turns instead
    +        # of being re-initialized (#120116). No initialize_all — the providers are already bound.
    +        agent._memory_manager = memory_manager
    +    elif not skip_memory:
             try:
                 _mem_provider_name = mem_config.get("provider", "") if mem_config else ""
                 if not is_core_memory_provider(_mem_provider_name):
    ```

    ```diff
    @@ -2345,7 +2350,7 @@ def init_agent(
         checkpoint_max_snapshots: int = 20, checkpoint_max_total_size_mb: int = 500,
         checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False,
         requested_provider: str = None, capabilities: Optional[Dict[str, bool]] = None, cwd: Optional[str] = None,
    -    side_agent: bool = False,
    +    side_agent: bool = False, memory_manager=None,
     ):
         _install_safe_stdio()
     
    ```

    ```diff
    @@ -2424,7 +2429,7 @@ def init_agent(
             _agent_cfg = {}
     
         _apply_display_config(agent, _agent_cfg, platform)
    -    _init_memory(agent, _agent_cfg, skip_memory, platform)
    +    _init_memory(agent, _agent_cfg, skip_memory, platform, memory_manager=memory_manager)
         _apply_agent_section(agent, _agent_cfg)
         cs = _parse_compression_config(agent, _agent_cfg)
         _config_context_length, _custom_providers, _effective_context_length, _model_cfg = _resolve_context_length(
    ```
  - `agent/relay_runtime.py`

    ```diff
    @@ -177,8 +177,12 @@ def _load_segments_config() -> dict[str, Any]:
         on_compaction = False
         max_turns = 0
         try:
    -        from gateway.run import _load_gateway_config  # late import
    -        telemetry = (_load_gateway_config().get("gateway") or {}).get("telemetry") or {}
    +        # Never import gateway.run here: its import-time env setup (_HERMES_GATEWAY, HERMES_QUIET,
    +        # TERMINAL_CWD := home) rebinds a CLI/TUI/cron host — hung approvals (#87183), `hermes -z`
    +        # running in $HOME without the launch dir's AGENTS.md (#95577). Same reader it delegates to.
    +        from hermes_cli.config_effective import load_user_config_effective
    +
    +        telemetry = (load_user_config_effective().get("gateway") or {}).get("telemetry") or {}
             segments = telemetry.get("session_segments") or {}
             on_compaction = bool(segments.get("on_compaction", False))
             try:
    ```
  - `agent/turn_iteration_prep.py`

    ```diff
    @@ -22,6 +22,21 @@
     
     logger = logging.getLogger("agent.conversation_loop")
     
    +
    +def _anchors_current_turn(messages: Any, idx: Any, user_message: Any) -> bool:
    +    """True when ``messages[idx]`` is this turn's user row (verbatim, or its user-originated view)."""
    +    if not isinstance(idx, int) or not 0 <= idx < len(messages):
    +        return False
    +    msg = messages[idx]
    +    if not (isinstance(msg, dict) and msg.get("role") == "user"):
    +        return False
    +    if msg.get("content") == user_message:
    +        return True
    +    from agent.context_compressor import user_originated_turn_view
    +
    +    view = user_originated_turn_view(msg)
    +    return view is not None and view.get("content") == user_message
    +
     ITERATION_BUDGET_WARNING_TEMPLATE = (
         "[SYSTEM NOTICE — iteration budget checkpoint] You have used {used} of {maximum} "
         "iterations. Checkpoint durable progress now, then continue the task; do not stop "
    ```

    ```diff
    @@ -205,6 +220,18 @@ def _is_scaffold_ghost(msg: Dict[str, Any]) -> bool:
                         current_turn_user_idx, _reanchored_idx, agent.session_id or "-",
                     )
                     current_turn_user_idx = _reanchored_idx
    +    # Mid-turn compaction (post-tool gate, overflow restart, recovery) rebuilds ``messages`` without
    +    # handing back a new index. A stale index splits the request's replay prefix inside this turn's
    +    # tool rows: prefix canonicalization then drops the assistant tool_call whose result fell past the
    +    # split, the orphaned result is sanitized away, and the model silently loses tool output that
    +    # state.db still holds. A valid index always lands on this turn's user row; re-anchor otherwise.
    +    if user_message is not None and not _anchors_current_turn(messages, current_turn_user_idx, user_message):
    +        _reanchored_idx = _reanchor(agent, messages, user_message)
    +        request_logger.info(
    +            "Re-anchored stale current_turn_user_idx %s -> %s (session=%s)",
    +            current_turn_user_idx, _reanchored_idx, agent.session_id or "-",
    +        )
    +        current_turn_user_idx = _reanchored_idx
         return IterationPrep(
             action="fallthrough", messages=messages, request_logger=request_logger,
             current_turn_user_idx=current_turn_user_idx,
    ```
  - `agent/turn_overflow.py`

    ```diff
    @@ -23,7 +23,7 @@
     from agent.error_classifier import FailoverReason
     from agent.message_sanitization import serialized_messages_bytes
     from agent.model_metadata import (
    -    get_context_length_from_provider_error, is_output_cap_error,
    +    get_context_length_from_provider_error, is_local_endpoint, is_output_cap_error,
         parse_available_output_tokens_from_error,
     )
     from agent.turn_failure_copy import site_copy, stamp_failure
    ```

    ```diff
    @@ -402,11 +402,14 @@ def _recover_context_length(st: _Recovery, _retry: TurnRetryState, error_msg: st
         # request — a background review from an earlier session — holds their context. Name that,
         # keep the turn retryable and transient: no "conversation too long", no gateway auto-reset.
         # Only when the server quoted NO measurement of its own: "prompt is too long: 233153 tokens
    -    # > 200000" is the server's count and beats the local estimate.
    +    # > 200000" is the server's count and beats the local estimate. Local endpoints only: a hosted
    +    # route has no shared slot, so the same rejection means its real window is smaller than the one
    +    # Hermes assumes, and "wait and /retry" would fail identically forever; compress instead.
         window = agent.context_compressor.context_length
         request_tokens = st.request_tokens() + max(0, int(getattr(agent, "max_tokens", 0) or 0))
         if (
    -        not re.search(r"\d{4,}", error_msg)
    +        is_local_endpoint(agent.base_url)
    +        and not re.search(r"\d{4,}", error_msg)
             and isinstance(window, int) and window > 0
             and request_tokens < window * _UNEXPLAINED_REJECTION_FRACTION
         ):
    ```
  - `agent/turn_preflight.py`

    ```diff
    @@ -22,7 +22,7 @@
     from agent.turn_context import _review_fork_first_request_pending
     from agent.turn_context_compaction import (
         _apply_grown_window, _blocked_compress_reason, _clear_overflow_warn, _refund_api_call,
    -    _reset_retry_state_after_compaction,
    +    _reanchor, _reset_retry_state_after_compaction,
     )
     
     logger = logging.getLogger("agent.conversation_loop")
    ```

    ```diff
    @@ -238,13 +238,14 @@ class PostToolCompressionVerdict:
         compression_attempts: int
         final_response: Any
         turn_exit_reason: Any
    +    current_turn_user_idx: int
     
     
     def compress_after_tool_results(
         agent: Any, *, messages: List[Dict[str, Any]], system_message: Any, user_message: Any,
         active_system_prompt: Any, conversation_history: Any, compression_attempts: int,
         max_compression_attempts: int, effective_task_id: Any, final_response: Any,
    -    turn_exit_reason: Any,
    +    turn_exit_reason: Any, current_turn_user_idx: int,
     ) -> PostToolCompressionVerdict:
         """Post-tool-call compression decision. Pressure comes from API-reported
         ``prompt_tokens`` (a tight lower bound; thinking models inflate completion tokens),
    ```

    ```diff
    @@ -263,6 +264,7 @@ def _verdict(end_turn: bool) -> PostToolCompressionVerdict:
                 end_turn=end_turn, messages=messages, active_system_prompt=active_system_prompt,
                 conversation_history=conversation_history, compression_attempts=compression_attempts,
                 final_response=final_response, turn_exit_reason=turn_exit_reason,
    +            current_turn_user_idx=current_turn_user_idx,
             )
     
         _compressor = agent.context_compressor
    ```

    ```diff
    @@ -355,6 +357,7 @@ def _verdict(end_turn: bool) -> PostToolCompressionVerdict:
                         final_response = _HANDOFF_SKIP_FINAL_RESPONSE
                     turn_exit_reason = "compaction_handoff_not_actionable"
                     return _verdict(True)
    +            current_turn_user_idx = _reanchor(agent, messages, user_message)
         elif agent.compression_enabled:
             # Over threshold but compression blocked (cooldown/anti-thrash): deduped
             # warning so context can't silently overflow. ``attempts_spent`` names the
    ```
  - `agent/turn_tool_round.py`

    ```diff
    @@ -39,6 +39,7 @@ class ToolRoundVerdict:
         failed: Any
         _turn_exit_reason: Any
         truncated_tool_call_retries: Any
    +    current_turn_user_idx: Any
         result: Optional[Dict[str, Any]] = None
     
     
    ```

    ```diff
    @@ -47,7 +48,7 @@ def run_tool_round(
         conversation_history: Any, api_call_count: Any, effective_task_id: Any, user_message: Any,
         system_message: Any, active_system_prompt: Any, compression_attempts: Any,
         max_compression_attempts: Any, final_response: Any, failed: Any, _turn_exit_reason: Any,
    -    truncated_tool_call_retries: Any,
    +    truncated_tool_call_retries: Any, current_turn_user_idx: Any,
     ) -> ToolRoundVerdict:
         """Execute one tool round in the exact original order. Persist-before-execute is a
         durability invariant: resume must see the executed block if a destructive tool restarts
    ```

    ```diff
    @@ -60,7 +61,8 @@ def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ToolRoundV
                 action=action, messages=messages, conversation_history=conversation_history,
                 active_system_prompt=active_system_prompt, compression_attempts=compression_attempts,
                 final_response=final_response, failed=failed, _turn_exit_reason=_turn_exit_reason,
    -            truncated_tool_call_retries=truncated_tool_call_retries, result=result,
    +            truncated_tool_call_retries=truncated_tool_call_retries,
    +            current_turn_user_idx=current_turn_user_idx, result=result,
             )
     
         if not agent.quiet_mode:
    ```

    ```diff
    @@ -191,6 +193,7 @@ def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ToolRoundV
             compression_attempts=compression_attempts,
             max_compression_attempts=max_compression_attempts, effective_task_id=effective_task_id,
             final_response=final_response, turn_exit_reason=_turn_exit_reason,
    +        current_turn_user_idx=current_turn_user_idx,
         )
         messages = _ptc.messages
         active_system_prompt = _ptc.active_system_prompt
    ```

    ```diff
    @@ -198,6 +201,7 @@ def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ToolRoundV
         compression_attempts = _ptc.compression_attempts
         final_response = _ptc.final_response
         _turn_exit_reason = _ptc.turn_exit_reason
    +    current_turn_user_idx = _ptc.current_turn_user_idx
         if _ptc.end_turn:
             return _verdict("break")
     
    ```
  - `apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts`

    ```diff
    @@ -218,6 +218,11 @@ export function useSessionTileDelegate({
               }
             }
           },
    +      dropRuntimeBindings: storedSessionIds => {
    +        for (const storedSessionId of storedSessionIds) {
    +          runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
    +        }
    +      },
           // Reconnect reconcile (#93059): retire an orphaned runtime's busy claim
           // through updateSessionState so the cache, focused view, busyRef and
           // tile mirrors settle together. A runtime this cache never held reports
    ```
  - `apps/desktop/src/app/session/hooks/use-session-actions.test.tsx`

    ```diff
    @@ -93,6 +93,8 @@ import { NEW_CHAT_ROUTE, sessionRoute } from '../../routes'
     import type { ClientSessionState } from '../../types'
     
     import { useSessionActions } from './use-session-actions'
    +import { suppressTranscriptForView, transcriptRowContentKey } from './use-session-actions/transcript-provenance'
    +import type { TranscriptViewCutoff } from './use-session-actions/transcript-provenance'
     import { useSessionStateCache } from './use-session-state-cache'
     
     vi.mock('@/hermes', async importOriginal => ({
    ```

    ```diff
    @@ -992,6 +994,10 @@ function ResumeHarness({
       const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
       const runtimeMapRef = runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>())
       const stateMapRef = sessionStateByRuntimeIdRef ?? ref(new Map<string, ClientSessionState>())
    +  // Stand in for the real hook's gate: without it this stub would publish the
    +  // unproven warm cache before REST authority lands. Kept here (not per-test)
    +  // so every resume through this harness sees one shared suppression policy.
    +  const heldCutoffsByRuntimeIdRef = ref(new Map<string, TranscriptViewCutoff>())
     
       const actions = useSessionActions({
         activeSessionId: null,
    ```

    ```diff
    @@ -1008,7 +1014,25 @@ function ResumeHarness({
         selectedStoredSessionId,
         selectedStoredSessionIdRef: ref<string | null>(selectedStoredSessionId),
         sessionStateByRuntimeIdRef: stateMapRef,
    -    syncSessionStateToView: (sessionId, state) => onViewSync?.(sessionId, state),
    +    holdSessionTranscriptView: runtimeId => {
    +      const rows = stateMapRef.current.get(runtimeId)?.messages ?? []
    +
    +      const cutoff: TranscriptViewCutoff = {
    +        cutoffIds: new Set(rows.map(message => message.id)),
    +        cutoffKeys: new Set(rows.map(transcriptRowContentKey))
    +      }
    +
    +      heldCutoffsByRuntimeIdRef.current.set(runtimeId, cutoff)
    +
    +      return () => {
    +        heldCutoffsByRuntimeIdRef.current.delete(runtimeId)
    +      }
    +    },
    +    syncSessionStateToView: (sessionId, state) => {
    +      const cutoff = heldCutoffsByRuntimeIdRef.current.get(sessionId) ?? null
    +
    +      onViewSync?.(sessionId, suppressTranscriptForView(state, cutoff))
    +    },
         updateSessionState: (sessionId, updater, storedSessionId) => {
           // Full default shape (not a bare {} cast) so seeded/derived fields like
           // turnStartedAt behave as in production state updates.
    ```
  - `apps/desktop/src/app/session/hooks/use-session-actions/index.ts`

    ```diff
    @@ -147,7 +147,6 @@ import { projectPendingConnection, restorePendingConnectionFromSnapshot } from '
     import {
       createPersistedDisplayTranscriptProvenance,
       hasPersistedDisplayTranscriptProvenance,
    -  suppressTranscriptForView,
       withoutTranscriptProvenance
     } from './transcript-provenance'
     import {
    ```

    ```diff
    @@ -1244,14 +1243,16 @@ export function useSessionActions({
               dropSessionState(cachedRuntimeId)
             } else {
               // Bind the warm runtime immediately so cwd/workspace ownership don't
    -          // wait on session.activate (#71254). Unproven cache entries (no
    -          // persisted-display provenance) stay off the view until REST
    -          // authority lands — a compressed runtime tail is legal in cache and
    -          // is exactly the session-switch flicker (#73646). Proven caches and
    -          // same-session re-resumes still paint immediately. The persisted
    -          // refresh itself still starts after activate reattaches the live
    -          // transport, so a turn finishing between snapshot and reattach
    -          // cannot leave a stale partial on screen.
    +          // wait on session.activate (#71254). The armed transcript gate is the
    +          // single suppression authority: it hides only the unproven cached
    +          // prefix (rows captured at arm time, including a compressed runtime
    +          // tail — exactly the session-switch flicker, #73646) while rows that
    +          // arrive live during the hold still paint (#117867). Proven caches
    +          // and same-session re-resumes never arm the gate, so they paint
    +          // immediately. The persisted refresh itself still starts after
    +          // activate reattaches the live transport, so a turn finishing
    +          // between snapshot and reattach cannot leave a stale partial on
    +          // screen.
               const shouldRefreshPersistedTranscript = !isWatchWindow()
     
               const suppressUnprovenWarmTranscript =
    ```

    ```diff
    @@ -1277,10 +1278,7 @@ export function useSessionActions({
               selectedStoredSessionIdRef.current = storedSessionId
               setActiveSessionId(cachedRuntimeId)
               activeSessionIdRef.current = cachedRuntimeId
    -          syncSessionStateToView(
    -            cachedRuntimeId,
    -            suppressTranscriptForView(cachedViewState, suppressUnprovenWarmTranscript)
    -          )
    +          syncSessionStateToView(cachedRuntimeId, cachedViewState)
               setCurrentCwdTransient(cachedViewState.cwd)
               // The warm cache IS this conversation's own workspace truth, so the
               // switch is already re-homed here. This claim cannot wait for
    ```

    ```diff
    @@ -1433,10 +1431,7 @@ export function useSessionActions({
                   busyRef.current = running
                   setBusy(running)
                   setAwaitingResponse(running && !pendingClarify)
    -              syncSessionStateToView(
    -                cachedRuntimeId,
    -                suppressTranscriptForView(activatedLivenessState, suppressUnprovenWarmTranscript)
    -              )
    +              syncSessionStateToView(cachedRuntimeId, activatedLivenessState)
     
                   // session.activate is the ordering barrier for reconnect recovery:
                   // it atomically rebinds a running turn before returning. If the
    ```
  - `apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.test.ts`

    ```diff
    @@ -52,8 +52,26 @@ describe('transcript provenance', () => {
         const state = createClientSessionState('stored-1')
         state.messages = [{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }]
     
    -    expect(suppressTranscriptForView(state, false)).toBe(state)
    -    expect(suppressTranscriptForView(state, true).messages).toEqual([])
    +    // No gate held: the state reaches the view verbatim.
    +    expect(suppressTranscriptForView(state, null)).toBe(state)
    +
    +    // Gate held with no captured prefix: fail-closed, everything hides.
    +    expect(suppressTranscriptForView(state, { cutoffIds: new Set() }).messages).toEqual([])
         expect(state.messages).toHaveLength(1)
       })
    +
    +  it('drops only the cached prefix and keeps rows appended after the arm (#117867)', () => {
    +    const state = createClientSessionState('stored-1')
    +    state.messages = [
    +      { id: 'cached-1', role: 'user', parts: [{ type: 'text', text: 'old' }] },
    +      { id: 'live-1', role: 'assistant', parts: [{ type: 'text', text: 'streaming' }] }
    +    ]
    +
    +    const suppressed = suppressTranscriptForView(state, { cutoffIds: new Set(['cached-1']) })
    +
    +    expect(suppressed.messages.map(message => message.id)).toEqual(['live-1'])
    +
    +    // Nothing else in the state is touched, and the input is not mutated.
    +    expect(state.messages).toHaveLength(2)
    +  })
     })
    ```
  - `apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.ts`

    ```diff
    @@ -1,3 +1,5 @@
    +import type { ChatMessage } from '@/lib/chat-messages'
    +
     import type { ClientSessionState, PersistedDisplayTranscriptProvenance } from '../../../types'
     
     export type TranscriptProvenanceScope =
    ```

    ```diff
    @@ -60,10 +62,56 @@ export function invalidatePersistedDisplayTranscriptAuthority(state: ClientSessi
       }
     }
     
    -export function suppressTranscriptForView(state: ClientSessionState, suppress: boolean): ClientSessionState {
    -  if (!suppress || state.messages.length === 0) {
    +export interface TranscriptViewCutoff {
    +  cutoffIds: ReadonlySet<string>
    +  // Content fingerprints of the arm-time rows (optional). Compaction
    +  // re-sequences the cached tail with FRESH row ids mid-hold
    +  // (archive_and_compact: "consumers that reference durable row ids
    +  // re-resolve by content"), so an id-only cutoff would pass the whole
    +  // re-sequenced cached prefix as if it were live and paint the exact
    +  // compressed tail the hold exists to hide (#73646 via #117867).
    +  cutoffKeys?: ReadonlySet<string>
    +}
    +
    +// Volatile-free content fingerprint: role + text of text parts, JSON of the
    +// rest. Deliberately ignores row ids and per-row timestamps so a re-sequenced
    +// copy of the same content fingerprints identically. Ceiling: a genuinely new
    +// row whose content is byte-identical to an arm-time row stays hidden until
    +// the hold releases (bounded by the REST window).
    +export function transcriptRowContentKey(message: ChatMessage): string {
    +  return `${message.role}:${
    +    (message.parts ?? [])
    +      .map(part =>
    +        'text' in part && typeof part.text === 'string' ? `${part.type}:${part.text}` : JSON.stringify(part)
    +      )
    +      .join('|')
    +  }`
    +}
    +
    +export function suppressTranscriptForView(
    +  state: ClientSessionState,
    +  cutoff: TranscriptViewCutoff | null
    +): ClientSessionState {
    +  if (cutoff === null) {
    +    return state
    +  }
    +
    +  if (cutoff.cutoffIds.size === 0) {
    +    // Fail-closed: the gate was armed before any cached row existed, so there
    +    // is no unproven prefix to hide selectively — everything stays off the
    +    // view until REST authority lands (#73646).
    +    return { ...state, messages: [] }
    +  }
    +
    +  const messages = state.messages.filter(
    +    message =>
    +      !cutoff.cutoffIds.has(message.id) &&
    +      !(cutoff.cutoffKeys?.has(transcriptRowContentKey(message)) ?? false)
    +  )
    +
    +  if (messages.length === state.messages.length) {
         return state
       }
     
    -  return { ...state, messages: [] }
    +  return { ...state, messages }
     }
    ```
  - `apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx`

    ```diff
    @@ -742,6 +742,169 @@ describe('useSessionStateCache — cross-thread error isolation', () => {
       })
     })
     
    +// #117867: the warm-resume transcript gate used to empty the view entirely
    +// while held, so rows arriving LIVE during the hold (the user's in-flight turn)
    +// never painted until REST authority landed. The gate now hides only the cached
    +// prefix captured when the hold was armed.
    +describe('useSessionStateCache — held transcript gate keeps live rows (#117867)', () => {
    +  const runtime = 'hold-runtime'
    +  const stored = 'hold-stored'
    +
    +  beforeEach(() => {
    +    clearAllSessionStates()
    +    setActiveSessionId(runtime)
    +  })
    +
    +  afterEach(() => {
    +    cleanup()
    +    $messages.set([])
    +    clearAllSessionStates()
    +    setActiveSessionId(null)
    +  })
    +
    +  it('paints rows appended after the arm and hides only the cached prefix', () => {
    +    let cache!: Cache
    +    let release: (() => void) | undefined
    +
    +    render(<ViewHarness activeSessionId={runtime} onReady={value => (cache = value)} />)
    +
    +    // Seed the warm cache entry with the unproven cached prefix — exactly what
    +    // resumeSession has in hand when it arms the hold.
    +    act(() => {
    +      cache.updateSessionState(
    +        runtime,
    +        state => ({
    +          ...state,
    +          messages: [userMessage('cached-1', 'cached prompt'), assistantText('cached-2', 'cached tail')]
    +        }),
    +        stored
    +      )
    +    })
    +
    +    // The real production arm path, after the cache entry exists.
    +    act(() => {
    +      release = cache.holdSessionTranscriptView(runtime)
    +    })
    +
    +    // A live row arrives mid-hold. busy:false forces the critical-transition
    +    // sync flush (no rAF), so $messages is observable here.
    +    act(() => {
    +      cache.updateSessionState(
    +        runtime,
    +        state => ({
    +          ...state,
    +          busy: false,
    +          messages: [
    +            userMessage('cached-1', 'cached prompt'),
    +            assistantText('cached-2', 'cached tail'),
    +            userMessage('live-1', 'live turn')
    +          ]
    +        }),
    +        stored
    +      )
    +    })
    +
    +    const ids = $messages.get().map(message => message.id)
    +
    +    expect(ids).toContain('live-1')
    +    expect(ids).not.toContain('cached-1')
    +    expect(ids).not.toContain('cached-2')
    +
    +    // Releasing restores the full transcript.
    +    act(() => release?.())
    +    act(() => {
    +      cache.updateSessionState(
    +        runtime,
    +        state => ({ ...state, messages: state.messages }),
    +        stored
    +      )
    +    })
    +
    +    expect($messages.get().map(message => message.id)).toEqual(['cached-1', 'cached-2', 'live-1'])
    +  })
    +
    +  it('fails closed when the hold was armed with no cached prefix', () => {
    +    let cache!: Cache
    +    let release: (() => void) | undefined
    +
    +    render(<ViewHarness activeSessionId={runtime} onReady={value => (cache = value)} />)
    +
    +    act(() => {
    +      release = cache.holdSessionTranscriptView(runtime)
    +    })
    +
    +    act(() => {
    +      cache.updateSessionState(
    +        runtime,
    +        state => ({
    +          ...state,
    +          busy: false,
    +          messages: [userMessage('late-1', 'arrived after the arm')]
    +        }),
    +        stored
    +      )
    +    })
    +
    +    // No baseline was captured, so nothing is trustworthy yet: today's
    +    // hide-everything behavior is preserved until REST authority lands.
    +    expect($messages.get()).toEqual([])
    +
    +    act(() => release?.())
    +  })
    +
    +  it('keeps a re-sequenced cached tail hidden when compaction assigns fresh ids mid-hold (#117867)', () => {
    +    let cache!: Cache
    +    let release: (() => void) | undefined
    +
    +    render(<ViewHarness activeSessionId={runtime} onReady={value => (cache = value)} />)
    +
    +    act(() => {
    +      cache.updateSessionState(
    +        runtime,
    +        state => ({
    +          ...state,
    +          messages: [userMessage('cached-1', 'cached prompt'), assistantText('cached-2', 'cached tail')]
    +        }),
    +        stored
    +      )
    +    })
    +
    +    act(() => {
    +      release = cache.holdSessionTranscriptView(runtime)
    +    })
    +
    +    // A mid-hold compaction re-sequences the cached tail with FRESH row ids
    +    // while keeping the content (archive_and_compact contract: consumers that
    +    // reference durable row ids re-resolve by content). An id-only cutoff
    +    // would pass `seq-*` through as if it were live and paint the compressed
    +    // tail the hold exists to hide (#73646); the content fingerprint keeps it
    +    // hidden while a genuinely new row still paints.
    +    act(() => {
    +      cache.updateSessionState(
    +        runtime,
    +        state => ({
    +          ...state,
    +          busy: false,
    +          messages: [
    +            userMessage('seq-71', 'cached prompt'),
    +            assistantText('seq-72', 'cached tail'),
    +            userMessage('live-1', 'live turn')
    +          ]
    +        }),
    +        stored
    +      )
    +    })
    +
    +    const ids = $messages.get().map(message => message.id)
    +
    +    expect(ids).toContain('live-1')
    +    expect(ids).not.toContain('seq-71')
    +    expect(ids).not.toContain('seq-72')
    +
    +    act(() => release?.())
    +  })
    +})
    +
     // #93059: reconnect used to downgrade the $sessionStates mirror only, leaving
     // this cache (which warm resume ORs over `running: false`) still busy.
     describe('useSessionStateCache — reconnect busy reconcile (#93059)', () => {
    ```
  - `apps/desktop/src/app/session/hooks/use-session-state-cache.ts`

    ```diff
    @@ -35,10 +35,20 @@ import { SessionStateCache } from '../session-state-cache'
     
     import {
       invalidatePersistedDisplayTranscriptAuthority,
    -  suppressTranscriptForView
    +  suppressTranscriptForView,
    +  transcriptRowContentKey
     } from './use-session-actions/transcript-provenance'
     import { chatMessageArraysEquivalent } from './use-session-actions/utils'
     
    +// A held transcript gate hides only the unproven CACHED prefix (the ids
    +// captured when the hold was armed) — rows that arrive live during the hold
    +// still paint, which is the point of the fix (#117867).
    +interface TranscriptViewGate {
    +  cutoffIds: ReadonlySet<string>
    +  cutoffKeys: ReadonlySet<string>
    +  token: symbol
    +}
    +
     interface SessionStateCacheOptions {
       activeSessionId: string | null
       busyRef: MutableRefObject<boolean>
    ```

    ```diff
    @@ -143,7 +153,7 @@ export function useSessionStateCache({
       const sessionStateCache = sessionStateByRuntimeIdRef.current
       const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
       const viewSyncRafRef = useRef<number | null>(null)
    -  const transcriptViewGateByRuntimeIdRef = useRef(new Map<string, symbol>())
    +  const transcriptViewGateByRuntimeIdRef = useRef(new Map<string, TranscriptViewGate>())
       // Runtime id whose transcript currently occupies `$messages` — lets the
       // flush below tell a same-session refresh from a thread switch.
       const viewSessionIdRef = useRef<string | null>(null)
    ```

    ```diff
    @@ -226,16 +236,25 @@ export function useSessionStateCache({
         }
       }, [])
     
    -  const holdSessionTranscriptView = useCallback((runtimeId: string): (() => void) => {
    -    const token = [redacted:secret-assignment]
    -    transcriptViewGateByRuntimeIdRef.current.set(runtimeId, token)
    +  const holdSessionTranscriptView = useCallback(
    +    (runtimeId: string): (() => void) => {
    +      const token = [redacted:secret-assignment]
    +      const cached = sessionStateCache.get(runtimeId)
     
    -    return () => {
    -      if (transcriptViewGateByRuntimeIdRef.current.get(runtimeId) === token) {
    -        transcriptViewGateByRuntimeIdRef.current.delete(runtimeId)
    +      transcriptViewGateByRuntimeIdRef.current.set(runtimeId, {
    +        cutoffIds: new Set((cached?.messages ?? []).map(message => message.id)),
    +        cutoffKeys: new Set((cached?.messages ?? []).map(transcriptRowContentKey)),
    +        token
    +      })
    +
    +      return () => {
    +        if (transcriptViewGateByRuntimeIdRef.current.get(runtimeId)?.token === token) {
    +          transcriptViewGateByRuntimeIdRef.current.delete(runtimeId)
    +        }
           }
    -    }
    -  }, [])
    +    },
    +    [sessionStateCache]
    +  )
     
       const flushPendingViewState = useCallback(() => {
         const pending = pendingViewStateRef.current
    ```

    ```diff
    @@ -300,7 +319,8 @@ export function useSessionStateCache({
             return
           }
     
    -      const viewState = suppressTranscriptForView(state, transcriptViewGateByRuntimeIdRef.current.has(sessionId))
    +      const gate = transcriptViewGateByRuntimeIdRef.current.get(sessionId)
    +      const viewState = suppressTranscriptForView(state, gate ?? null)
     
           syncRuntimeMetadataToView(viewState)
           pendingViewStateRef.current = { sessionId, state: viewState }
    ```
  - `apps/desktop/src/store/gateway-connection-lifecycle.test.ts`

    ```diff
    @@ -31,6 +31,7 @@ const gatewayMocks = vi.hoisted(() => {
     
     const reconnectStateMocks = vi.hoisted(() => ({
       reconcileBusyStatesOnReconnect: vi.fn(),
    +  resetRouteOwnedTileRuntimeBindings: vi.fn(),
       resetTileRuntimeBindings: vi.fn()
     }))
     
    ```

    ```diff
    @@ -352,11 +353,14 @@ describe('secondary reconnect runtime scope', () => {
         const reopening = openGatewayForAgent('homelab', 'writer')
     
         await vi.waitFor(() => expect(gatewayMocks.connect).toHaveBeenCalledTimes(2))
    -    expect(reconnectStateMocks.resetTileRuntimeBindings).toHaveBeenCalledWith({
    +    // Not the window's ambient gateway: only tiles owned by this route can hold
    +    // its runtime ids, so the reset is route-owned rather than window-wide.
    +    expect(reconnectStateMocks.resetRouteOwnedTileRuntimeBindings).toHaveBeenCalledWith({
           connectionId: 'homelab',
           profile: 'writer'
         })
    -    expect(reconnectStateMocks.resetTileRuntimeBindings.mock.invocationCallOrder[0]).toBeLessThan(
    +    expect(reconnectStateMocks.resetTileRuntimeBindings).not.toHaveBeenCalled()
    +    expect(reconnectStateMocks.resetRouteOwnedTileRuntimeBindings.mock.invocationCallOrder[0]).toBeLessThan(
           gatewayMocks.connect.mock.invocationCallOrder[1]
         )
     
    ```

    ```diff
    @@ -364,6 +368,30 @@ describe('secondary reconnect runtime scope', () => {
         await reopening
       })
     
    +  it('does not re-resume unrelated tiles when a background request lease reopens its route', async () => {
    +    // The Bot relay drains every registered connection on a 30s tick through
    +    // requestGatewayForAgent. A local route is exempt from relay retention, so
    +    // each tick dials a fresh socket and disposes it after the RPC. Treating
    +    // every such reopen as a window-wide backend restart dropped every open
    +    // tile's runtime binding, remounting its composer (caret reset, layout
    +    // shift, model pick reverted) every 30 seconds.
    +    installDesktop({
    +      getConnectionFor: vi.fn(async ({ connectionId, profile }) => descriptorFor(connectionId, profile))
    +    })
    +
    +    await requestGatewayForAgent('local', 'writer', 'bot_relay.outbox.drain')
    +    expect(gatewayMocks.instances[0]?.close).toHaveBeenCalledOnce()
    +
    +    await requestGatewayForAgent('local', 'writer', 'bot_relay.outbox.drain')
    +
    +    expect(gatewayMocks.connect).toHaveBeenCalledTimes(2)
    +    expect(reconnectStateMocks.resetTileRuntimeBindings).not.toHaveBeenCalled()
    +    expect(reconnectStateMocks.resetRouteOwnedTileRuntimeBindings).toHaveBeenCalledWith({
    +      connectionId: 'local',
    +      profile: 'writer'
    +    })
    +  })
    +
       it('rebinds only Bot runtimes owned by the reconnected profile route', async () => {
         installDesktop({
           getConnectionFor: vi.fn(async ({ connectionId, profile }) => descriptorFor(connectionId, profile))
    ```
  - `apps/desktop/src/store/gateway.ts`

    ```diff
    @@ -696,12 +696,21 @@ async function openSecondary(entry: Secondary, spawnPriority: SpawnPriority = 'b
     
         if (reopening) {
           try {
    -        const { reconcileBusyStatesOnReconnect, resetTileRuntimeBindings } = await import('@/store/session-states')
    +        const { reconcileBusyStatesOnReconnect, resetRouteOwnedTileRuntimeBindings, resetTileRuntimeBindings } =
    +          await import('@/store/session-states')
    +
    +        const scope = { connectionId: entry.connectionId || 'local', profile: entry.profile }
    +
    +        // Only the window's ambient gateway carries un-owned tiles and the main
    +        // thread. A background route (e.g. a relay request lease that disposed
    +        // its socket after the last tick) can only have minted runtimes for
    +        // tiles that name it as their owner route.
    +        if (g.activeKey === entry.scope) {
    +          resetTileRuntimeBindings(scope)
    +        } else {
    +          resetRouteOwnedTileRuntimeBindings(scope)
    +        }
     
    -        resetTileRuntimeBindings({
    -          connectionId: entry.connectionId || 'local',
    -          profile: entry.profile
    -        })
             reconcileBusyAfterOpen = () => reconcileBusyStatesOnReconnect(entry.scope)
           } catch {
             // Best effort for partial test/HMR graphs. Production always loads the
    ```
  - `apps/desktop/src/store/session-states.test.ts`

    ```diff
    @@ -43,6 +43,7 @@ import {
       recordSessionEventScope,
       releaseSessionTranscript,
       requestForOwnedSession,
    +  resetRouteOwnedTileRuntimeBindings,
       resetTileRuntimeBindings,
       selectionHomesToWorkspace,
       type SessionTileDelegate,
    ```

    ```diff
    @@ -261,6 +262,58 @@ describe('resetTileRuntimeBindings', () => {
       })
     })
     
    +describe('resetRouteOwnedTileRuntimeBindings', () => {
    +  afterEach(() => {
    +    $sessionTiles.set([])
    +  })
    +
    +  it('drops only tiles owned by the reopened route and leaves ambient tiles bound', () => {
    +    const invalidateRuntimeBindings = vi.fn()
    +    const dropRuntimeBindings = vi.fn()
    +    setSessionTileDelegate({ dropRuntimeBindings, invalidateRuntimeBindings } as unknown as SessionTileDelegate)
    +    $sessionTiles.set([
    +      {
    +        ownerRoute: { connectionId: 'local', mode: 'local', profile: 'writer', targetProfile: 'writer' },
    +        runtimeId: 'runtime-writer-bot',
    +        storedSessionId: 'stored-writer-bot',
    +        workspaceMode: 'bots'
    +      },
    +      {
    +        ownerRoute: { connectionId: 'local', mode: 'local', profile: 'coder', targetProfile: 'coder' },
    +        runtimeId: 'runtime-coder-bot',
    +        storedSessionId: 'stored-coder-bot',
    +        workspaceMode: 'bots'
    +      },
    +      { runtimeId: 'runtime-ambient', storedSessionId: 'stored-ambient' }
    +    ])
    +
    +    resetRouteOwnedTileRuntimeBindings({ connectionId: 'local', profile: 'writer' })
    +
    +    const [writerBot, coderBot, ambient] = $sessionTiles.get()
    +
    +    expect(writerBot).toMatchObject({ storedSessionId: 'stored-writer-bot' })
    +    expect(writerBot).not.toHaveProperty('runtimeId')
    +    expect(coderBot).toMatchObject({ runtimeId: 'runtime-coder-bot' })
    +    expect(ambient).toMatchObject({ runtimeId: 'runtime-ambient' })
    +    expect(dropRuntimeBindings).toHaveBeenCalledWith(new Set(['stored-writer-bot']))
    +    expect(invalidateRuntimeBindings).not.toHaveBeenCalled()
    +  })
    +
    +  it('is a no-op when no open tile belongs to the reopened route', () => {
    +    const invalidateRuntimeBindings = vi.fn()
    +    const dropRuntimeBindings = vi.fn()
    +    setSessionTileDelegate({ dropRuntimeBindings, invalidateRuntimeBindings } as unknown as SessionTileDelegate)
    +    const tiles = [{ runtimeId: 'runtime-ambient', storedSessionId: 'stored-ambient' }]
    +    $sessionTiles.set(tiles)
    +
    +    resetRouteOwnedTileRuntimeBindings({ connectionId: 'local', profile: 'writer' })
    +
    +    expect($sessionTiles.get()).toBe(tiles)
    +    expect(dropRuntimeBindings).not.toHaveBeenCalled()
    +    expect(invalidateRuntimeBindings).not.toHaveBeenCalled()
    +  })
    +})
    +
     describe('SessionTile workspace scope', () => {
       afterEach(() => {
         $activeGatewayProfile.set('default')
    ```
  - `apps/desktop/src/store/session-states.ts`

    ```diff
    @@ -1527,6 +1527,43 @@ export function resetTileRuntimeBindings(
       }
     }
     
    +/** Reset for a pooled secondary route that reopened while it was NOT the
    + *  window's ambient gateway. Only tiles whose exact owner route names that
    + *  runtime can hold ids it minted; un-owned tiles and the main thread ride the
    + *  ambient socket, whose own reconnect path runs `resetTileRuntimeBindings`.
    + *  Background request leases (the Bot relay drain) reopen such a route every
    + *  tick, and a window-wide reset there re-resumed every open tile each time —
    + *  remounting its composer (caret reset, layout shift, model pick reverted). */
    +export function resetRouteOwnedTileRuntimeBindings(scope: RuntimeReconnectScope) {
    +  const connectionId = scope.connectionId.trim()
    +  const profile = scope.profile?.trim() || null
    +  const tiles = $sessionTiles.get()
    +
    +  const ownedStoredIds = new Set(
    +    tiles
    +      .filter(tile => {
    +        const route = tile.ownerRoute
    +
    +        return (
    +          Boolean(connectionId) &&
    +          route?.connectionId === connectionId &&
    +          (!profile || (route.targetProfile || route.profile) === profile)
    +        )
    +      })
    +      .map(tile => tile.storedSessionId)
    +  )
    +
    +  if (ownedStoredIds.size === 0) {
    +    return
    +  }
    +
    +  sessionTileDelegate()?.dropRuntimeBindings?.(ownedStoredIds)
    +
    +  if (tiles.some(tile => tile.runtimeId && ownedStoredIds.has(tile.storedSessionId))) {
    +    $sessionTiles.set(tiles.map(tile => (ownedStoredIds.has(tile.storedSessionId) ? toStored(tile) : tile)))
    +  }
    +}
    +
     /** Unbind ONE reclaimed runtime from whichever tile holds it — the targeted
      *  sibling of resetTileRuntimeBindings. The reconnect-time reset can't cover a
      *  backend reclaim: the WS re-dials immediately, but the orphan reaper fires a
    ```

    ```diff
    @@ -1569,6 +1606,11 @@ export interface SessionTileDelegate {
        *  warm path re-binds tiles to dead runtime ids (the sleep/wake "empty
        *  right pane" bug). Bindings re-record from live post-reconnect events. */
       invalidateRuntimeBindings?(preserveStoredSessionIds?: ReadonlySet<string>): void
    +  /** Drop ONLY these stored→runtime bindings from the wiring cache — the
    +   *  route-scoped twin of invalidateRuntimeBindings for a reopened secondary
    +   *  (`resetRouteOwnedTileRuntimeBindings`). Bindings for every other stored
    +   *  session, including the main thread, stay warm. */
    +  dropRuntimeBindings?(storedSessionIds: ReadonlySet<string>): void
       /** Bind a live runtime id for a stored session (resume without touching
        *  the main view). Returns the runtime id, or throws.
        *  `refreshTranscript` forces a REST merge even when a warm cached
    ```
  - `contributors/emails/[redacted:email]`

    ```diff
    @@ -0,0 +1 @@
    +a1398394385
    ```
  - `gateway/AGENTS.md`

    ```diff
    @@ -210,6 +210,13 @@ gateway under the backend, and do NOT "fix" update locks by widening the tree-ki
       Resolve the owning home from the session record (`profile_home`, `agent:<profile>:` key), never
       from `os.environ`, which holds the launch profile. Why: eviction that flushed under the launch scope
       wrote a secondary profile's memories into the default profile's store, silently.
    +- **`api_server` rebuilds the agent per request but not the memory provider.** The adapter bypasses
    +  `TurnRunner` and the agent cache (per-request callbacks, model route, ephemeral prompt), so
    +  `platforms/api_server_memory_sessions.py` parks each session's initialised `MemoryManager` between
    +  requests (exclusive check-out in `_create_agent`, check-in in the turn's `finally`, keyed by profile
    +  home + `agent.session_id`; idle/LRU eviction shuts down under the owning home) and
    +  `AIAgent(memory_manager=...)` adopts it without a second provider init. Without it the previous
    +  turn's queued recall never reaches the next request (#120116).
     - **`multiplex_profiles: false` is not "no scope ever".** A native hosted room serving a second
       profile flips the process-wide guard (`tui_gateway/launch_profile_policy.py::
       activate_multi_profile_hosting`) inside the gateway process, after the adapters were wired; every
    ```
  - `gateway/config_env.py`

    ```diff
    @@ -316,9 +316,15 @@ def _api_server(config: GatewayConfig) -> None:
     
     
     def _webhook(config: GatewayConfig) -> None:
    -    if is_truthy_value(getenv("WEBHOOK_ENABLED")):
    -        extra = _enable_from_env(config, Platform.WEBHOOK, pop_marker=True, warn=False).extra
    -        _env_extras(extra, (("port", "WEBHOOK_PORT", _INT), ("secret", "WEBHOOK_SECRET")))
    +    enabled = is_truthy_value(getenv("WEBHOOK_ENABLED"))
    +    if not (enabled or Platform.WEBHOOK in config.platforms):
    +        return
    +    webhook_config = (
    +        _enable_from_env(config, Platform.WEBHOOK, pop_marker=True, warn=False)
    +        if enabled
    +        else config.platforms[Platform.WEBHOOK]
    +    )
    +    _env_extras(webhook_config.extra, (("port", "WEBHOOK_PORT", _INT), ("secret", "WEBHOOK_SECRET")))
     
     
     def _msgraph_webhook(config: GatewayConfig) -> None:
    ```
  - `gateway/config_loader.py`

    ```diff
    @@ -393,6 +393,14 @@ def read_yaml_layers(home: Path) -> dict:
             with open(config_yaml_path, encoding="utf-8") as f:
                 yaml_cfg = yaml.safe_load(f) or {}
     
    +    from hermes_cli.config import _expand_env_vars
    +
    +    # ${VAR} / ${env:VAR} expansion — the same primitive the CLI loader applies, so platform
    +    # adapter settings (webhook secret, api_server key, teams credentials) arrive resolved
    +    # instead of as literal refs. User layer first: the managed overlay is expanded by
    +    # apply_managed_overlay itself and must not be re-resolved (config_effective._effective order).
    +    yaml_cfg = _expand_env_vars(yaml_cfg)
    +
         # Managed scope: overlay administrator-pinned values (this loader bypasses
         # hermes_cli.config.load_config, so managed quick_commands / stt would otherwise be ignored).
         from hermes_cli import managed_scope
    ```
  - `gateway/platforms/api_server.py`

    ```diff
    @@ -122,6 +122,7 @@ def _approval_event_choices(*, smart_denied: bool, allow_session: bool, allow_pe
     from gateway.platforms import api_server_room_grants as _room_grants
     from gateway.platforms import api_server_runs as _api_runs
     from gateway.platforms.api_server_openai_routes import OpenAICompatRoutesMixin
    +from gateway.platforms.api_server_memory_sessions import ApiServerMemorySessions
     from gateway.platforms.base import (
         MEDIA_TAG_CLEANUP_RE, BasePlatformAdapter, SendResult, _terminal_sentinel_start, is_network_accessible,
         validate_media_delivery_path)
    ```

    ```diff
    @@ -1214,6 +1215,8 @@ def __init__(self, config: PlatformConfig):
             # Every agent inside _run_agent() for shutdown interrupt, keyed by id() (the strong ref
             # keeps the id() from recycling); distinct from the run_id-keyed _active_run_agents.
             self._shutdown_interruptible_agents: Dict[int, Any] = {}
    +        # One memory provider per session across requests (this surface rebuilds the agent per turn).
    +        self._memory_sessions = ApiServerMemorySessions()
             self.gateway_runner: Optional[Any] = None  # set by gateway/run.py
             # Admitted requests not yet in agent bookkeeping, so shutdown drain counts them.
             self._pending_agent_requests: int = 0
    ```

    ```diff
    @@ -2236,7 +2239,10 @@ def _create_agent(
                 # Same fallback provider chain as Telegram/Discord/Slack.
                 "fallback_model": None if confirmed_runtime_lock else GatewayRunner._load_fallback_model(),
                 "reasoning_config": request_reasoning_config,
    -            "gateway_session_key": gateway_session_key}
    +            "gateway_session_key": gateway_session_key,
    +            # The session's provider from the previous request, so its queued recall reaches this turn
    +            # (#120116); checked back in by the turn's finally.
    +            "memory_manager": self._memory_sessions.checkout(session_id)}
             if request_service_tier is not _REQUEST_OPTION_MISSING:
                 agent_kwargs["service_tier"] = request_service_tier
             agent = AIAgent(**agent_kwargs)
    ```

    ```diff
    @@ -4034,6 +4040,7 @@ def _run():
                         if agent is not None:
                             _clear_turn_process_ownership(agent)
                             self._shutdown_interruptible_agents.pop(id(agent), None)
    +                        self._memory_sessions.checkin(agent)
                             # Bind the declared key to the row the turn actually ended on
                             # (agent.session_id carries a mid-turn rotation). Opt-in per route.
                             # Record the declared conversation on the row the turn actually ended on —
    ```

    ```diff
    @@ -4288,6 +4295,8 @@ async def disconnect(self) -> None:
                 except Exception:
                     logger.debug("Failed to close response store for %s", self.name, exc_info=True)
             _api_runs._close_run_state(self)
    +        with suppress(Exception):
    +            await asyncio.to_thread(self._memory_sessions.close_all)
             try:
                 if self._site:
                     await self._site.stop()
    ```
  - `gateway/platforms/api_server_memory_sessions.py`

    ```diff
    @@ -0,0 +1,136 @@
    +"""Memory-provider continuity across api_server requests (#120116).
    +
    +The api_server adapter builds a fresh ``AIAgent`` per request (per-request callbacks, model
    +route, ephemeral prompt), unlike the messaging platforms, whose cached agent — and with it the
    +memory provider — lives for the whole session. External providers deliver recall as the
    +PREVIOUS turn's background prefetch held on the provider instance, so a provider that is
    +re-initialised per request never has anything to inject, and each init re-runs the provider's
    +startup (for an embedded daemon: a restart that also kills the retain still in flight).
    +
    +This registry keeps one initialised ``MemoryManager`` per (profile home, session id): a request
    +checks the session's manager out before building its agent (``AIAgent(memory_manager=...)``
    +skips provider init) and checks it back in when the turn ends. Check-out is exclusive, so two
    +concurrent requests on one session never share a manager; the loser's fresh manager is shut down
    +when it checks in behind the winner. Idle entries and LRU overflow are shut down under the owning
    +profile's scope, like the gateway agent cache's eviction.
    +"""
    +
    +from __future__ import annotations
    +
    +import logging
    +import threading
    +import time
    +from collections import OrderedDict
    +from contextlib import nullcontext, suppress
    +from pathlib import Path
    +from typing import Any, Dict, List, Optional, Tuple
    +
    +logger = logging.getLogger(__name__)
    +
    +
    +class ApiServerMemorySessions:
    +    """Session-keyed ``MemoryManager`` registry with exclusive check-out/check-in."""
    +
    +    def __init__(self, *, max_size: Optional[int] = None, idle_ttl_secs: Optional[float] = None) -> None:
    +        self._entries: "OrderedDict[Tuple[str, str], Tuple[Any, Optional[Path], float]]" = OrderedDict()
    +        self._lock = threading.Lock()
    +        self._max_size = max_size
    +        self._idle_ttl_secs = idle_ttl_secs
    +
    +    # -- bounds (same knobs as the gateway agent cache, resolved lazily) -------------------------
    +
    +    def _bounds(self) -> Tuple[int, float]:
    +        if self._max_size is None or self._idle_ttl_secs is None:
    +            from gateway.run import _AGENT_CACHE_IDLE_TTL_SECS, _AGENT_CACHE_MAX_SIZE, _load_gateway_config
    +            from gateway.agent_cache_pressure import resolve_agent_cache_bounds
    +            configured = None
    +            with suppress(Exception):
    +                configured = resolve_agent_cache_bounds(_load_gateway_config())
    +            if self._max_size is None:
    +                self._max_size = getattr(configured, "max_size", None) or _AGENT_CACHE_MAX_SIZE
    +            if self._idle_ttl_secs is None:
    +                self._idle_ttl_secs = getattr(configured, "idle_ttl_secs", None) or _AGENT_CACHE_IDLE_TTL_SECS
    +        return self._max_size, self._idle_ttl_secs
    +
    +    @staticmethod
    +    def _owner_home() -> Tuple[str, Optional[Path]]:
    +        """(registry key, profile home to re-enter on eviction) for the CURRENT scope. Callers run
    +        inside ``_profile_scope`` (or a single-profile gateway), so the ambient home is the owner's."""
    +        from hermes_constants import get_hermes_home, hermes_home_key
    +        home = Path(get_hermes_home())
    +        return hermes_home_key(home), home
    +
    +    # -- check-out / check-in ----------------------------------------------------------------
    +
    +    def checkout(self, session_id: Optional[str]) -> Optional[Any]:
    +        """The manager a previous request on ``session_id`` checked in, or None (build a new one)."""
    +        if not session_id:
    +            return None
    +        home_key, _home = self._owner_home()
    +        with self._lock:
    +            entry = self._entries.pop((home_key, session_id), None)
    +        return entry[0] if entry else None
    +
    +    def checkin(self, agent: Any) -> None:
    +        """Park ``agent``'s manager under the session the turn ended on (``agent.session_id`` carries a
    +        mid-turn compression rotation) and shut down whatever this displaces or has gone idle."""
    +        manager = getattr(agent, "_memory_manager", None)
    +        session_id = str(getattr(agent, "session_id", "") or "")
    +        if manager is None or not session_id:
    +            return
    +        home_key, home = self._owner_home()
    +        max_size, idle_ttl = self._bounds()
    +        now = time.monotonic()
    +        doomed: List[Tuple[Any, Optional[Path]]] = []
    +        with self._lock:
    +            displaced = self._entries.pop((home_key, session_id), None)
    +            if displaced is not None and displaced[0] is not manager:
    +                doomed.append((displaced[0], displaced[1]))
    +            self._entries[(home_key, session_id)] = (manager, home, now)
    +            for key, (mgr, owner, last_used) in list(self._entries.items()):
    +                if mgr is manager:
    +                    continue
    +                if now - last_used > idle_ttl or len(self._entries) > max_size:
    +                    del self._entries[key]
    +                    doomed.append((mgr, owner))
    +        for mgr, owner in doomed:
    +            self._shutdown_async(mgr, owner)
    +
    +    def close_all(self) -> None:
    +        """Adapter shutdown: drain and shut down every parked manager (inline: the process is ending)."""
    +        with self._lock:
    +            entries = list(self._entries.values())
    +            self._entries.clear()
    +        for mgr, owner, _ in entries:
    +            self._shutdown(mgr, owner)
    +
    +    # -- teardown -------------------------------------------------------------------------------
    +
    +    def _shutdown_async(self, manager: Any, owner: Optional[Path]) -> None:
    +        """Eviction runs inside a request's own turn: never make that reply wait on a provider drain."""
    +        from agent.memory_provider import spawn_context_thread
    +        spawn_context_thread(self._shutdown, args=(manager, owner), name="api-server-memory-evict").start()
    +
    +    @staticmethod
    +    def _shutdown(manager: Any, owner: Optional[Path]) -> None:
    +        """Bounded drain then provider shutdown, under the OWNING profile's scope: eviction runs inside
    +        whichever request happened to trigger it, and a provider reads its home/credentials at call time."""
    +        scope: Any = nullcontext()
    +        with suppress(Exception):
    +            from agent.secret_scope import is_multiplex_active
    +            if owner is not None and is_multiplex_active():
    +                from gateway.run import _profile_runtime_scope
    +                scope = _profile_runtime_scope(owner)
    +        try:
    +            with scope:
    +                with suppress(Exception):
    +                    manager.flush_pending(timeout=10)
    +                manager.shutdown_all()
    +        except Exception:
    +            logger.debug("api_server memory manager shutdown failed", exc_info=True)
    +
    +    # -- introspection (tests) --------------------------------------------------------------------
    +
    +    def parked(self) -> Dict[Tuple[str, str], Any]:
    +        with self._lock:
    +            return {key: entry[0] for key, entry in self._entries.items()}
    ```
  - `gateway/platforms/api_server_runs.py`

    ```diff
    @@ -780,6 +780,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve
             finally:
                 # Clear ownership now so a later stop can't reap work this run left running.
                 _api_server._clear_turn_process_ownership(agent)
    +            self._memory_sessions.checkin(agent)
                 # Declared-conversation binding, same precedence gate as _run_agent.
                 if run.declared_selected:
                     self._bind_declared_conversation(
    ```
  - `gateway/run.py`

    ```diff
    @@ -5264,6 +5264,8 @@ def restart_signal_handler():
     
     def _start_gateway_make_shutdown_signal_handler(runner, _signal_initiated_shutdown: list):
         """Build the SIGINT/SIGTERM handler; ``_signal_initiated_shutdown[0]`` records an unplanned signal."""
    +    planned_stop_seen = [False]
    +
         def shutdown_signal_handler(received_signal=None):
             # Planned --replace takeover (sibling marked this PID): exit 0 so systemd won't revive us.
             def _takeover() -> bool:
    ```

    ```diff
    @@ -5283,6 +5285,12 @@ def _snapshot():
             planned_takeover = bool(_best_effort(_takeover, "Takeover marker check failed: %s"))
             planned_stop = received_signal == signal.SIGINT or (
                 not planned_takeover and bool(_best_effort(_planned_stop, "Planned stop marker check failed: %s")))
    +        # `hermes gateway stop` writes the marker, THEN signals: the planned-stop watcher can consume
    +        # the marker in between, and the CLI's own SIGTERM must not then read as an external kill.
    +        if planned_stop:
    +            planned_stop_seen[0] = True
    +        elif planned_stop_seen[0] and not planned_takeover:
    +            planned_stop = True
             _shutdown_ctx = _best_effort(_snapshot, "snapshot_shutdown_context failed: %s")
             sig_name = _shutdown_ctx["signal"] if _shutdown_ctx else None
     
    ```
  - `gateway/run_agent_cache.py`

    ```diff
    @@ -160,10 +160,13 @@ def _rehydrate_session_model_override(self, session_key: str) -> None:
                 return
             override: Dict[str, Any] = {k: persisted.get(k) for k in ("model", "provider", "base_url")}
             provider = persisted.get("provider")
    +        from hermes_cli.runtime_provider import is_foreign_provider_endpoint
    +        if is_foreign_provider_endpoint(provider, override.get("base_url")):
    +            override["base_url"] = None  # left over from a switch that kept the previous provider's URL
             if provider:
                 # Re-resolve credentials for the persisted provider. On failure (e.g. credentials removed
                 # since the switch) keep the credential-less override — _resolve_session_agent_runtime
    -            # falls back to env resolution and layers model/provider.
    +            # retries the resolution for that provider on each turn (default route + notice meanwhile).
                 try:
                     runtime = _resolve_runtime_agent_kwargs_for_provider(provider, target_model=persisted.get("model") or None)
                     for k in ("api_key", "api_mode", "credential_pool", "requested_provider", "max_tokens"):
    ```
  - `gateway/run_turn.py`

    ```diff
    @@ -206,7 +206,8 @@ def _resolve_session_agent_runtime(
                         skey or "", model, override_model, override_runtime.get("provider"),
                     )
                     return override_model, override_runtime
    -            # No api_key on the override: env-based resolution below, override model/provider on top.
    +            # No api_key on the override (credentials failed to re-resolve at rehydrate): resolve them
    +            # for the override's own provider below, never layer it over the default provider's runtime.
                 logger.debug(
                     "Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s",
                     skey or "", model, override_model,
    ```

    ```diff
    @@ -221,7 +222,19 @@ def _resolve_session_agent_runtime(
                     ][:5] or "[]",
                 )
     
    -        runtime_kwargs = _resolve_runtime_agent_kwargs()
    +        runtime_kwargs, unavailable_override = None, None
    +        if override and override.get("provider"):
    +            try:
    +                runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider(
    +                    override["provider"], target_model=override.get("model") or None)
    +            except Exception as exc:
    +                # Layering the override on the default runtime sent its model to the default provider's
    +                # endpoint (openai-codex on the Nous URL). Run this turn on the whole default route and say
    +                # so; the persisted override is kept, so the next turn retries it.
    +                logger.warning("Session /model override provider %s unavailable: %s", override["provider"], exc)
    +                unavailable_override, override = override, None
    +        if runtime_kwargs is None:
    +            runtime_kwargs = _resolve_runtime_agent_kwargs()
             # Private notice metadata must never reach an ``AIAgent(**runtime_kwargs)`` spread; the turn
             # runner surfaces it through the agent's one-shot fallback notice (#74349).
             self._pre_agent_fallback_notice = runtime_kwargs.pop("_fallback_notice", None)
    ```

    ```diff
    @@ -229,6 +242,10 @@ def _resolve_session_agent_runtime(
             if runtime_model:
                 logger.info("Runtime provider supplied explicit model override: %s -> %s", model, runtime_model)
                 model = runtime_model
    +        if unavailable_override and not self._pre_agent_fallback_notice:
    +            from hermes_cli.fallback_config import pre_agent_fallback_notice
    +            self._pre_agent_fallback_notice = pre_agent_fallback_notice(
    +                unavailable_override["provider"], unavailable_override.get("model"), runtime_kwargs.get("provider"), model)
     
             cfg = getattr(self, "config", None)  # getattr: bare object.__new__ test runners
             if cfg and source is not None:
    ```
  - `gateway/slash_commands_model.py`

    ```diff
    @@ -215,6 +215,7 @@ async def _record_model_switch(
                         _sess_entry.was_auto_reset = False
                     await _sess_db.update_session_model(
                         _sess_entry.session_id, result.new_model, provider=result.target_provider,
    +                    base_url=result.base_url, api_mode=result.api_mode,
                     )
                 except Exception as exc:
                     logger.debug("Failed to persist model switch to DB: %s", exc)
    ```
  - `hermes_cli/AGENTS.md`

    ```diff
    @@ -152,10 +152,15 @@ it guards. `plan → snapshot → apply → restart-per-kind → verify → repo
       none of them; without the graft the swap deletes them). Post-swap, the Desktop
       rebuild decision also trusts the build stamp under HERMES_HOME, so an install that already lost
       its artifacts in an earlier update is rebuilt instead of "forgotten" (#90495).
    -- **Restart-per-kind**: systemd and launchd restarts are FLEET-WIDE (every `hermes-gateway*` unit /
    -  `ai.hermes.gateway*` LaunchAgent), drain-first (SIGUSR1), with per-unit/per-label failure
    -  isolation. Restarting only the invoking profile's service leaves siblings on stale `sys.modules`
    -  until they crash — the largest dupe-PR cluster in the repo's history came from that bug.
    +- **Restart-per-kind**: systemd and launchd restarts are FLEET-WIDE within the updating install (every
    +  `hermes-gateway*` unit / `ai.hermes.gateway*` LaunchAgent whose home is the updating root or one of its
    +  `profiles/<name>`), drain-first (SIGUSR1), with per-unit/per-label failure isolation. Restarting only the
    +  invoking profile's service leaves siblings on stale `sys.modules` until they crash — the largest dupe-PR
    +  cluster in the repo's history came from that bug. The fleet is bounded by HOME, not by namespace:
    +  `hermes_cli/update_fleet_scope.py` judges every unit/label/process by the home it actually runs on
    +  (live environ, unit `Environment=`, plist `HERMES_HOME`), and a runtime of another `HERMES_HOME` on the
    +  same account — a sibling install, the real `hermes-gateway.service` seen from a scratch home — is named and
    +  left alone, never restarted (#93349).
     - **Verify**: gateways stamp `code_sha`/`code_version` into `gateway_state.json` on every
       runtime-status write (`gateway/status.py`); the updater compares each live gateway against the
       fresh checkout and prints a fleet version matrix. A provably-stale gateway fails the update
    ```
  - `hermes_cli/cli_model_switch_mixin.py`

    ```diff
    @@ -61,6 +61,10 @@ def stored_session_route(session_meta, *, current_model, current_provider):
         if stored_model == current_model and not provider_changed:
             return None
         api_mode = runtime.get("api_mode") or None
    +    from hermes_cli.runtime_provider import is_foreign_provider_endpoint
    +    if is_foreign_provider_endpoint(provider, base_url):
    +        # The endpoint and its wire belong to the provider this chat left; resolve the stored one's own.
    +        base_url = api_mode = None
         # A row's api_mode/base_url were written for whichever model the session last ran. Providers that
         # pick the wire per model (OpenCode Zen/Go, Copilot, Nous) re-derive both from the stored model, or a
         # resumed opencode-go session keeps a MiniMax-era anthropic_messages route for a chat_completions
    ```
  - `hermes_cli/profile_cmd.py`

    ```diff
    @@ -215,6 +215,10 @@ def _profile_create(args):
                 print(f"Full copy from {source_label} (excluding session history, cron jobs, backups, and snapshots).")
             else:
                 print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.")
    +            from hermes_cli.profile_memory_config import cloned_memory_provider
    +            memory_provider = cloned_memory_provider(profile_dir)
    +            if memory_provider:
    +                print(f"Cloned memory provider config ({memory_provider}) too.")
             if sync_imports:
                 print(f"Import sources carried over — `hermes -p {name} import-agent --sync` "
                       "keeps pulling the same Claude Code / Codex trees.")
    ```
  - `hermes_cli/profile_memory_config.py`

    ```diff
    @@ -0,0 +1,71 @@
    +"""Carry the ACTIVE memory provider's own config into a ``--clone`` (#120115).
    +
    +``--clone`` copies ``config.yaml`` — and with it ``memory.provider: hindsight`` — but the
    +provider keeps its settings outside config.yaml, so the clone booted with the provider
    +selected and silently unavailable. Providers store per-home config by convention (the same
    +convention ``hermes_cli.web_routers.memory_providers`` reads): a ``<home>/<provider>/``
    +directory (hindsight) or a flat ``<home>/<provider>.json`` (mem0, honcho, supermemory). Copying
    +by convention keeps this free of plugin imports: the provider may live in the catalog, not in
    +tree, so a hook the plugin must implement could not fix the reported case.
    +"""
    +
    +import contextlib
    +import os
    +import re
    +import shutil
    +from pathlib import Path
    +from typing import Optional
    +
    +# A provider name is a bare directory/file stem; anything else (path separators, ``..``, spaces)
    +# would let a hand-edited config.yaml aim the copy outside the source profile.
    +_PROVIDER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
    +
    +
    +def active_memory_provider(config: Optional[dict]) -> Optional[str]:
    +    """The external ``memory.provider`` named in a parsed config.yaml, or None for the built-in
    +    store or an unsafe name."""
    +    from agent.memory_provider import is_core_memory_provider
    +
    +    memory = (config or {}).get("memory")
    +    name = memory.get("provider") if isinstance(memory, dict) else None
    +    if not isinstance(name, str) or is_core_memory_provider(name):
    +        return None
    +    name = name.strip()
    +    if name in {".", ".."} or not _PROVIDER_NAME_RE.match(name):
    +        return None
    +    return name
    +
    +
    +def clone_memory_provider_config(source_dir: Path, profile_dir: Path, provider: Optional[str]) -> bool:
    +    """Copy ``<provider>/`` and/or ``<provider>.json`` from *source_dir* into *profile_dir* when
    +    present. Files land owner-only like ``.env``: they can hold an API key. Returns True when
    +    anything was copied."""
    +    if not provider:
    +        return False
    +    copied = False
    +    src_dir = source_dir / provider
    +    if src_dir.is_dir():
    +        shutil.copytree(src_dir, profile_dir / provider, dirs_exist_ok=True)
    +        for root, _dirs, files in os.walk(profile_dir / provider):
    +            for filename in files:
    +                with contextlib.suppress(OSError):
    +                    os.chmod(os.path.join(root, filename), 0o600)
    +        copied = True
    +    src_file = source_dir / f"{provider}.json"
    +    if src_file.is_file():
    +        dst = profile_dir / f"{provider}.json"
    +        shutil.copy2(src_file, dst)
    +        with contextlib.suppress(OSError):
    +            os.chmod(str(dst), 0o600)
    +        copied = True
    +    return copied
    +
    +
    +def cloned_memory_provider(profile_dir: Path) -> Optional[str]:
    +    """Name of the external provider whose config *profile_dir* now carries, for the CLI notice."""
    +    from hermes_cli.profiles import _load_yaml_dict
    +
    +    provider = active_memory_provider(_load_yaml_dict(profile_dir / "config.yaml"))
    +    if provider and ((profile_dir / provider).is_dir() or (profile_dir / f"{provider}.json").is_file()):
    +        return provider
    +    return None
    ```
  - `hermes_cli/profiles.py`

    ```diff
    @@ -1196,6 +1196,9 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path],
             _copytree_keep_junctions(source_skills, profile_dir / "skills", _non_exportable_entries, dirs_exist_ok=True)
         for relpath in _CLONE_SUBDIR_FILES:
             _clone_file(source_dir, profile_dir, relpath)
    +    from hermes_cli.profile_memory_config import active_memory_provider, clone_memory_provider_config
    +    clone_memory_provider_config(source_dir, profile_dir,
    +                                 active_memory_provider(_load_yaml_dict(source_dir / "config.yaml")))
         if sync_imports:
             from hermes_cli.agent_import_sync import SYNC_MANIFEST_NAME  # lazy: keeps yaml/utils off the hot startup path
             _clone_file(source_dir, profile_dir, SYNC_MANIFEST_NAME)
    ```
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -317,6 +317,20 @@ def _config_base_url_for_provider(model_cfg: Dict[str, Any], provider: str) -> s
         return str(model_cfg.get("base_url") or "").strip().rstrip("/") if _same_registered_provider(provider, configured_provider) else ""
     
     
    +def is_foreign_provider_endpoint(provider: Optional[str], base_url: Optional[str]) -> bool:
    +    """True when ``base_url`` is another built-in provider's canonical endpoint, not ``provider``'s.
    +
    +    A persisted session route that pairs one provider with another's endpoint is left over from a
    +    switch that kept the old URL (openai-codex + the Nous Portal URL sent the Codex slug to the Portal).
    +    Only registered providers are judged: a custom or proxy URL is never another provider's canonical one.
    +    """
    +    pconfig = PROVIDER_REGISTRY.get(str(provider or "").strip().lower())
    +    url = str(base_url or "").strip().rstrip("/")
    +    if pconfig is None or not url or url == (pconfig.inference_base_url or "").rstrip("/"):
    +        return False
    +    return any(url == (other.inference_base_url or "").rstrip("/") for other in PROVIDER_REGISTRY.values())
    +
    +
     def _anthropic_base_url_override_ok(base_url: str) -> bool:
         """Whether a configured ``model.base_url`` plausibly speaks the Anthropic Messages protocol:
         official Anthropic/Claude hosts, Azure Foundry, or ``/anthropic`` / Kimi ``/coding`` proxies
    ```
  - `hermes_cli/update_cmd_deps.py`

    ```diff
    @@ -4,12 +4,14 @@
     
     import logging
     from contextlib import suppress
    +import ast
     import hashlib
     import json
     import os
     import shutil
     import subprocess
     import sys
    +import tomllib
     from pathlib import Path
     from typing import Optional
     from hermes_constants import project_venv_dir, venv_python_path
    ```

    ```diff
    @@ -22,11 +24,83 @@
     _INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "uv.lock"
     
     
    +def _mapping_literal(tree: ast.AST):
    +    """The setuptools finder uses ``MAPPING: dict[str, str] = {...}``, not a bare assign."""
    +    for node in tree.body:
    +        if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "MAPPING":
    +            return ast.literal_eval(node.value)
    +        if isinstance(node, ast.Assign) and any(
    +            isinstance(target, ast.Name) and target.id == "MAPPING" for target in node.targets
    +        ):
    +            return ast.literal_eval(node.value)
    +    return None
    +
    +
    +def _checkout_import_names(root: Path) -> set[str]:
    +    """Top-level names an editable install records: root modules plus configured packages."""
    +    names = {path.stem for path in root.glob("*.py") if path.name != "setup.py"}
    +    with (root / "pyproject.toml").open("rb") as stream:
    +        includes = (
    +            tomllib.load(stream)
    +            .get("tool", {})
    +            .get("setuptools", {})
    +            .get("packages", {})
    +            .get("find", {})
    +            .get("include", [])
    +        )
    +    prefixes = {item.removesuffix(".*") for item in includes if isinstance(item, str)}
    +    names.update(
    +        path.name
    +        for path in root.iterdir()
    +        if path.is_dir() and (path / "__init__.py").is_file() and path.name in prefixes
    +    )
    +    return names
    +
    +
    +def _editable_finder_files(venv: Path) -> list[Path]:
    +    sites = [*venv.glob("lib/python*/site-packages"), venv / "Lib" / "site-packages"]
    +    return [
    +        finder
    +        for site in sites
    +        if site.is_dir()
    +        for finder in site.glob("__editable__*hermes_agent*finder.py")
    +    ]
    +
    +
    +def _editable_finder_mapping_current(cwd) -> bool | None:
    +    """None when the install venv has no static finder; False when its map misses the checkout.
    +
    +    Module entries point at the stem (``cli``, not ``cli.py``). Existence of that
    +    path is not the contract — the key set is. A dangling path is a different repair.
    +    """
    +    root = Path(cwd)
    +    venv = project_venv_dir(root)
    +    if venv is None:
    +        return None
    +    finders = _editable_finder_files(venv)
    +    if not finders:
    +        return None
    +    try:
    +        inventory = _checkout_import_names(root)
    +        for finder in finders:
    +            mapping = _mapping_literal(ast.parse(finder.read_text(encoding="utf-8")))
    +            if not isinstance(mapping, dict) or set(mapping) != inventory:
    +                return False
    +    except (OSError, SyntaxError, ValueError, TypeError, AttributeError):
    +        return False
    +    return True
    +
    +
     def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool:
    -    """True when the pulled commits cannot have invalidated the editable install: ``uv pip install
    -    -e .`` always rewrites console-script shims (Windows: ``hermes.exe`` quarantine, ``os error 32``
    -    on a lost race), so skip it when only non-install files changed. Safe because the editable
    -    finder uses a *static* module list. Fails closed: no pre-pull SHA or failed diff -> False."""
    +    """True when the pull cannot invalidate the editable install.
    +
    +    ``uv pip install -e .`` rewrites console-script shims. On Windows that rewrite
    +    quarantines the running ``hermes.exe``, and a lost race is the ``os error 32``
    +    family, so skip it only when packaging files are unchanged and the installed
    +    finder still names every top-level import the checkout exposes. No finder keeps
    +    the packaging-file gate. An unreadable finder, or a map that disagrees with the
    +    checkout, is not current. No pre-pull SHA or a failed diff fails closed.
    +    """
         if not pre_pull_sha:
             return False
         try:
    ```

    ```diff
    @@ -35,7 +109,10 @@ def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool
                 cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
         except OSError:
             return False
    -    return result.returncode == 0 and not result.stdout.strip()
    +    if result.returncode != 0 or result.stdout.strip():
    +        return False
    +    mapping_current = _editable_finder_mapping_current(cwd)
    +    return mapping_current is not False
     
     
     # Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are
    ```

    ```diff
    @@ -95,19 +172,28 @@ def _critical_module_import_failures(
                report_runtime_errors, marker))
         try:
             interpreter = sys.executable
    +        argv = [interpreter, "-c", probe]
             with suppress(Exception):
                 venv_dir = project_venv_dir(root) or Path(root) / "venv"
                 venv_python = venv_python_path(venv_dir, windows=_m()._is_windows())
                 if venv_python.exists():
                     interpreter = str(venv_python)
    +                argv = [interpreter, "-c", probe]
    +                # ``-c`` puts the cwd (the checkout) at sys.path[0], which masks the installed
    +                # editable finder — the exact thing a gateway started from ``/`` imports through.
    +                # A stale finder MAPPING (new top-level package since the install) then reports
    +                # green here and crash-loops the gateway (#119466). ``-P`` makes the probe see what
    +                # the venv sees; only when an editable install exists, so a bare dev checkout that
    +                # is importable through its cwd alone keeps its advisory verdict.
    +                if _editable_finder_files(venv_dir):
    +                    argv = [interpreter, "-P", "-c", probe]
             # The candidate stays importable through the probe's cwd and its editable install;
             # the scrub only removes paths the guard never meant to vouch for.
             probe_env = dict(os.environ)
             for denied_key in _PROBE_ENV_DENYLIST:
                 probe_env.pop(denied_key, None)
             result = bounded_probe_run(
    -            [interpreter, "-c", probe], timeout=120, cwd=str(root), raise_on_spawn_failure=True,
    -            env=probe_env,
    +            argv, timeout=120, cwd=str(root), raise_on_spawn_failure=True, env=probe_env,
             )
         except (OSError, subprocess.SubprocessError):
             # Keep this guard advisory: a probe we could not even spawn (unreadable venv
    ```
  - `hermes_cli/update_cmd_fleet.py`

    ```diff
    @@ -581,6 +581,8 @@ def _restart_systemd_gateway_units_best_effort(failed: list, listings) -> None:
     
         for key in keys:
             scope, scope_cmd, svc_name = targets[key]
    +        if not _systemd_unit_owned_by_update(scope_cmd, svc_name):
    +            continue
             manage_cmd = list(scope_cmd) + ["--no-ask-password"]
             if _needs_sudo(scope):
                 manage_cmd = ["sudo", "-n"] + manage_cmd
    ```

    ```diff
    @@ -867,6 +869,34 @@ def _systemctl_reset_and_restart(manage_cmd: list, svc_name: str, *, scope_cmd:
         return _systemctl(manage_cmd + ["restart", svc_name], timeout=timeout)
     
     
    +def _systemd_unit_owned_by_update(scope_cmd: list, svc_name: str) -> bool:
    +    """Gate a unit restart on the unit's home being one this update owns (#93349).
    +
    +    ``hermes-gateway*`` is an account-wide namespace: a second install's ``hermes update`` used
    +    to drain and restart the account's real ``hermes-gateway.service`` because the unit was
    +    listed, not because it ran the updated code. Foreign or unreadable ownership prints a notice
    +    and leaves the unit alone; it is not a failed restart.
    +    """
    +    from hermes_cli.update_fleet_scope import describe_skipped_runtime, systemd_unit_hermes_home, home_in_update_scope
    +    home = systemd_unit_hermes_home(scope_cmd, svc_name)
    +    if home is not None and home_in_update_scope(home):
    +        return True
    +    print(describe_skipped_runtime("systemd unit", svc_name, home))
    +    return False
    +
    +
    +def _scoped_manual_gateway_pids(pids, *, keep=(), quiet: bool = False) -> list[int]:
    +    """*pids* whose live home this update owns (plus *keep*, PIDs already mapped to this
    +    install's profile PID files); every other gateway process is named and left running."""
    +    from hermes_cli.update_fleet_scope import describe_skipped_runtime, partition_gateway_pids_by_scope
    +    keep = set(keep)
    +    owned, foreign = partition_gateway_pids_by_scope([pid for pid in pids if pid not in keep])
    +    if not quiet:
    +        for pid, home in foreign:
    +            print(describe_skipped_runtime("gateway process", f"PID {pid}", home))
    +    return [pid for pid in pids if pid in keep or pid in owned]
    +
    +
     def _is_hermes_gateway_unit(unit: str) -> bool:
         """Exact base unit or hyphenated profile family only: ``startswith("hermes-serve")``
         would accept ``hermes-server.service``."""
    ```

    ```diff
    @@ -945,7 +975,9 @@ def _warn_incomplete_gateway_fleet_restart(failed_units: list) -> None:
             print("    launchctl kickstart -k gui/$UID/<label>   # macOS (or user/$UID)")
     
     
    -def _restart_launchd_gateway_after_update(*, supervision_verify: bool = True) -> tuple[list, list]:
    +def _restart_launchd_gateway_after_update(
    +    *, supervision_verify: bool = True, self_restart_pending: set | None = None,
    +) -> tuple[list, list]:
         """Restart the invoking profile's launchd gateway after an update.
     
         No ``launchctl list`` gating: a booted-out job (plist present, definition
    ```

    ```diff
    @@ -964,7 +996,7 @@ def _restart_launchd_gateway_after_update(*, supervision_verify: bool = True) ->
         """
         from hermes_cli.gateway import (
             get_launchd_label, get_launchd_plist_path, launchd_restart, wait_for_launchd_gateway_supervision,
    -        _launchctl_supervised_pid,
    +        _is_pid_ancestor_of_current_process, _launchctl_supervised_pid,
         )
         current_label = get_launchd_label()
         old_pid = None
    ```

    ```diff
    @@ -998,6 +1030,13 @@ def _restart_launchd_gateway_after_update(*, supervision_verify: bool = True) ->
     
         if not supervision_verify:
             return [current_label], []
    +    if old_pid is not None and _is_pid_ancestor_of_current_process(old_pid):
    +        # launchd_restart() handed the restart to the gateway this updater runs INSIDE (cron job in
    +        # the gateway tree, #100179): it exits only after this process does, so no fresh supervised
    +        # pid can appear while we wait. Record it as pending for the fleet matrix (#119597).
    +        if self_restart_pending is not None:
    +            self_restart_pending.add(old_pid)
    +        return [current_label], []
     
         # launchd_restart() returning only means "restart REQUESTED" (async). A helper dying
         # before first bootstrap, or a bootstrap exiting 0 without registering (macOS 26.6.1),
    ```

    ```diff
    @@ -1016,6 +1055,7 @@ def _restart_launchd_gateway_after_update(*, supervision_verify: bool = True) ->
     
     def _restart_macos_launchd_gateways(
         restarted_services: list, failed_or_stale_units: list, drain_budget: float, *, require_supervision: bool = False,
    +    self_restart_pending: set | None = None,
     ) -> None:
         """Restart every launchd-managed gateway after an update (macOS).
     
    ```

    ```diff
    @@ -1040,7 +1080,8 @@ def _restart_macos_launchd_gateways(
             if listing.returncode != 0:
                 failed_or_stale_units.append("launchd (listing failed)")
                 return
    -    _restarted, _failed = _restart_launchd_gateway_after_update(supervision_verify=True)
    +    _restarted, _failed = _restart_launchd_gateway_after_update(
    +        supervision_verify=True, self_restart_pending=self_restart_pending)
         restarted_services.extend(_restarted)
         failed_or_stale_units.extend(_failed)
         current_label = get_launchd_label()
    ```

    ```diff
    @@ -1053,9 +1094,15 @@ def _restart_macos_launchd_gateways(
         legacy_labels = legacy_launchd_labels_for_install(exclude=set(derived_labels) | {current_label})
         if legacy_labels:
             print(f"  ↻ legacy-labelled units of this install join the restart: {', '.join(legacy_labels)}")
    +    from hermes_cli.update_fleet_scope import describe_skipped_runtime, launchd_label_foreign_home
         for label in derived_labels + legacy_labels:
             if label == current_label:
                 continue
    +        # Labels are account-global: root B's default profile derives the same bare label root A
    +        # installed. A plist pinning a foreign HERMES_HOME is another install's job (#93349).
    +        if (foreign_home := launchd_label_foreign_home(label)) is not None:
    +            print(describe_skipped_runtime("launchd job", label, foreign_home))
    +            continue
             try:
                 # Locate = liveness + domain in one probe; kickstart and fresh-PID checks
                 # reuse that domain so a sibling is never probed in one and restarted in another.
    ```

    ```diff
    @@ -1107,7 +1154,7 @@ def _surviving_gateway_pids_after_failed_restart():
         """
         try:
             from hermes_cli.gateway import find_gateway_pids
    -        return list(find_gateway_pids(all_profiles=True))
    +        return _scoped_manual_gateway_pids(find_gateway_pids(all_profiles=True), quiet=True)
         except Exception as exc:  # pragma: no cover - defensive
             logger.debug("Could not probe for surviving gateways after update: %s", exc)
             return None
    ```

    ```diff
    @@ -1202,7 +1249,9 @@ def _warn_gateway_restart_phase_aborted(exc: BaseException, pids) -> None:
         print("    hermes gateway status")
     
     
    -def _drain_or_signal_gateway_for_update(pid: int, drain_budget: float, label: str) -> bool:
    +def _drain_or_signal_gateway_for_update(
    +    pid: int, drain_budget: float, label: str, *, self_restart_pending: set | None = None,
    +) -> bool:
         """Three-way triage (shared by systemd and bare-process paths) for handing a
         running gateway over to new code. Returns True when signalled/stopped.
     
    ```

    ```diff
    @@ -1210,6 +1259,9 @@ def _drain_or_signal_gateway_for_update(pid: int, drain_budget: float, label: st
            tree): waiting is circular (gateway waits on in-flight work → cron session
            waits on update → update waits on gateway) and the 1800s force-drain cap burns.
            So fire-and-forget: signal restart and return; it completes once THIS process exits.
    +       The pid lands in ``self_restart_pending`` so the fleet matrix can tell "restart
    +       deferred until the updater exits" from "restart never happened" (#119597): the
    +       ancestor is still serving the old code when the matrix runs, by construction.
         2. Event loop provably wedged: SIGUSR1 can never drain it; bounded SIGTERM→SIGKILL.
         3. Live out-of-tree gateway: graceful SIGUSR1 drain up to ``drain_budget``.
     
    ```

    ```diff
    @@ -1227,7 +1279,10 @@ def _drain_or_signal_gateway_for_update(pid: int, drain_budget: float, label: st
                 "process tree — signalling restart and letting the gateway "
                 "drain itself (avoids the cron-update deadlock, #100179)"
             )
    -        return _request_gateway_self_restart(pid)
    +        accepted = _request_gateway_self_restart(pid)
    +        if accepted and self_restart_pending is not None:
    +            self_restart_pending.add(pid)
    +        return accepted
         if probe_gateway_loop_liveness(pid) == GATEWAY_LOOP_WEDGED:
             print(f"  ⚠ {label}: gateway event loop is unresponsive — skipping drain, forcing a bounded stop...")
             _escalate_wedged_gateway(pid)
    ```

    ```diff
    @@ -1326,7 +1381,7 @@ def _repair_unit_without_fatal_exit_park(svc_name: str, scope: str) -> None:
     
     def _restart_one_systemd_gateway_unit(
         svc_name: str, *, scope: str, scope_cmd: list, drain_budget: float, _manage_cmd_cache: dict,
    -    restarted_services: list, failed_or_stale_units: list,
    +    restarted_services: list, failed_or_stale_units: list, self_restart_pending: set | None = None,
     ) -> None:
         """Restart one active systemd gateway/serve unit: graceful SIGUSR1 drain, then forced restart.
     
    ```

    ```diff
    @@ -1335,6 +1390,8 @@ def _restart_one_systemd_gateway_unit(
         check = _systemctl(scope_cmd + ["is-active", svc_name], timeout=5)
         if check.stdout.strip() != "active":
             return
    +    if not _systemd_unit_owned_by_update(scope_cmd, svc_name):
    +        return
         _repair_unit_without_fatal_exit_park(svc_name, scope)
     
         # None ⇒ no non-interactive privilege path; avoid manage-units verbs
    ```

    ```diff
    @@ -1352,7 +1409,8 @@ def _restart_one_systemd_gateway_unit(
                 _main_pid = 0
     
         # Three-way triage (ancestor / wedged / graceful drain).
    -    _graceful_ok = _main_pid > 0 and _drain_or_signal_gateway_for_update(_main_pid, drain_budget, svc_name)
    +    _graceful_ok = _main_pid > 0 and _drain_or_signal_gateway_for_update(
    +        _main_pid, drain_budget, svc_name, self_restart_pending=self_restart_pending)
     
         if _graceful_ok:
             # ``Restart=always`` respawns only after RestartSec (60s in our unit; dead time for a
    ```

    ```diff
    @@ -1428,7 +1486,9 @@ def _restart_one_systemd_gateway_unit(
         )
     
     
    -def _restart_systemd_gateway_units(restarted_services, failed_or_stale_units, restarted_scoped_units, drain_budget):
    +def _restart_systemd_gateway_units(
    +    restarted_services, failed_or_stale_units, restarted_scoped_units, drain_budget, self_restart_pending=None,
    +):
         """Restart every active hermes-gateway*/hermes-serve* systemd unit (user + system).
     
         Settled units → ``restarted_services`` (bare) and ``restarted_scoped_units``
    ```

    ```diff
    @@ -1475,6 +1535,7 @@ def _on_unit_timeout(svc_name: str, exc: subprocess.TimeoutExpired) -> None:
                         _manage_cmd_cache=_manage_cmd_cache,
                         restarted_services=restarted_services,
                         failed_or_stale_units=failed_or_stale_units,
    +                    self_restart_pending=self_restart_pending,
                     ),
                     on_unit_timeout=_on_unit_timeout,
                 )
    ```

    ```diff
    @@ -1502,6 +1563,10 @@ class _GatewayRestartOutcome:
         #: ``scope/name`` of every settled systemd unit; the fleet probe stops waiting for a state stamp
         #: once none of them is active or activating any more (the successor died, nothing will publish).
         restarted_scoped_units: set = field(default_factory=set)
    +    #: Gateways that are ANCESTORS of this updater and accepted a self-restart request
    +    #: (``_drain_or_signal_gateway_for_update`` branch 1): they restart only after this process
    +    #: exits, so the fleet matrix renders them as pending instead of STALE (#119597).
    +    self_restart_pending_pids: set = field(default_factory=set)
     
         def fleet_probe_signals(self) -> tuple:
             """``(pre_restart_pids, killed_pids)`` with the unmapped stops removed — the signals that
    ```

    ```diff
    @@ -1541,6 +1606,9 @@ def _restart_manual_gateways(out: _GatewayRestartOutcome, _drain_budget) -> None
             for proc in find_profile_gateway_processes(exclude_pids=service_pids)
             if proc.pid in manual_pids
         }
    +    # ``all_profiles`` is host-wide: a sibling install's gateway matches too. Only this update's
    +    # homes are stopped; the profile-mapped PIDs come from this install's own PID files (#93349).
    +    manual_pids = _scoped_manual_gateway_pids(manual_pids, keep=profile_processes)
         # Profile gateways we couldn't arm a relaunch for must NOT keep running stale:
         # the unmapped sweep below stops them and lists them under "Restart manually".
         # These must NOT be left running: their modules are the pre-update ones and every lazy import from here
    ```

    ```diff
    @@ -1562,7 +1630,8 @@ def _restart_manual_gateways(out: _GatewayRestartOutcome, _drain_budget) -> None
             # SIGUSR1 drain first, SIGTERM fallback if unsupported/over budget — the watcher
             # relaunches either way. The helper announces its choice first because a silent
             # full-budget wait reads as a hung update.
    -        if not _drain_or_signal_gateway_for_update(pid, _drain_budget, proc.profile):
    +        if not _drain_or_signal_gateway_for_update(
    +                pid, _drain_budget, proc.profile, self_restart_pending=out.self_restart_pending_pids):
                 with suppress(ProcessLookupError, PermissionError):
                     os.kill(pid, _signal.SIGTERM)
             # Wait ≤5s for exit: Telegram keeps the old getUpdates session ~30s; a new gateway
    ```

    ```diff
    @@ -1756,18 +1825,22 @@ def _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode: bool):
             # Snapshot before any stop/drain so an empty survivor probe reads as "stopped
             # and never came back", not "nothing was running"; None fails closed.
             try:
    -            out.pre_restart_gateway_pids = list(find_gateway_pids(all_profiles=True))
    +            out.pre_restart_gateway_pids = _scoped_manual_gateway_pids(find_gateway_pids(all_profiles=True), quiet=True)
             except Exception:
                 out.pre_restart_gateway_pids = None
     
             _restart_systemd_gateway_units(
    -            out.restarted_services, out.failed_or_stale_units, restarted_scoped_units, _drain_budget
    +            out.restarted_services, out.failed_or_stale_units, restarted_scoped_units, _drain_budget,
    +            out.self_restart_pending_pids,
             )
     
             # macOS: EVERY ai.hermes.gateway* LaunchAgent (systemd parity).
             if is_macos():
                 with suppress(FileNotFoundError, ImportError):
    -                _restart_macos_launchd_gateways(out.restarted_services, out.failed_or_stale_units, _drain_budget)
    +                _restart_macos_launchd_gateways(
    +                    out.restarted_services, out.failed_or_stale_units, _drain_budget,
    +                    self_restart_pending=out.self_restart_pending_pids,
    +                )
     
             _restart_manual_gateways(out, _drain_budget)
     
    ```

    ```diff
    @@ -1822,19 +1895,21 @@ def _collect_fleet_snapshot(restart, rows_expected: bool) -> list:
         ``identity_pending`` so the matrix does not call it a pre-stamping gateway.
         """
         from hermes_cli.update_receipt import collect_fleet_versions
    +    pending = getattr(restart, "self_restart_pending_pids", None) or None
         if not rows_expected:
    -        return collect_fleet_versions(pre_restart_pids=restart.pre_restart_gateway_pids)
    +        return collect_fleet_versions(
    +            pre_restart_pids=restart.pre_restart_gateway_pids, self_restart_pending=pending)
         pre_pids = restart.pre_restart_gateway_pids
         _fleet_deadline = _time.monotonic() + _FLEET_PROBE_SETTLE_TIMEOUT_SECONDS
         while True:
             _time.sleep(2.0)
    -        snapshot = collect_fleet_versions(pre_restart_pids=pre_pids)
    -        pending = [row for row in snapshot if _fleet_row_identity_pending(row, pre_pids)]
    -        if snapshot and not pending and not any(row.get("state") == "down" for row in snapshot):
    +        snapshot = collect_fleet_versions(pre_restart_pids=pre_pids, self_restart_pending=pending)
    +        unstamped = [row for row in snapshot if _fleet_row_identity_pending(row, pre_pids)]
    +        if snapshot and not unstamped and not any(row.get("state") == "down" for row in snapshot):
                 return snapshot
             if _time.monotonic() >= _fleet_deadline or _restarted_units_gone(
                     getattr(restart, "restarted_scoped_units", ())):
    -            for row in pending:
    +            for row in unstamped:
                     row["identity_pending"] = True
                 return snapshot
     
    ```
  - `hermes_cli/update_cmd_windows.py`

    ```diff
    @@ -794,7 +794,8 @@ def _windows_cold_start_plan() -> dict | None:
         gateway that died without a clean exit. The Desktop hand-off exits the app before the updater starts
         and can kill the running gateway in those same seconds, so discovery finds no live PID to pause
         (#109538) — the dead attestation is the only surviving "a gateway was up" evidence, and the Desktop
    -    does not restart the messaging gateway itself. Keep the plan, and record the attestation
    +    only restarts gateways it stopped itself (its hand-off script, after the update verifies; #119809).
    +    Keep the plan, and record the attestation
         *generation* that authorized it on the token: the marker is a mutable one-shot that any concurrent
         ``hermes gateway status``/``start`` consumes, so execution authorizes the spawn from the token and
         consumes only that generation (#110020 review)."""
    ```
  - `hermes_cli/update_fleet_scope.py`

    ```diff
    @@ -0,0 +1,148 @@
    +"""Home scoping for ``hermes update``'s fleet restart (#93349).
    +
    +The restart phase enumerates ``hermes-gateway*``/``hermes-serve*`` units, ``ai.hermes.gateway*``
    +LaunchAgents and every ``gateway run`` process on the host. Those are HOST-wide namespaces: a
    +second Hermes install (another ``HERMES_HOME`` root under the same account, its own checkout and
    +venv) shares them, and the update used to restart that install's gateway too — including the
    +account's real ``hermes-gateway.service`` when a scratch home ran ``hermes update``.
    +
    +The fleet an update owns is the set of homes its plan inventories: the updating root plus every
    +``<root>/profiles/<name>``. Ownership is judged from what a runtime actually runs on — the live
    +process environment (``HERMES_HOME``/``HOME``, via ``_hermes_home_for_pid``), the unit's declared
    +``Environment=`` or the plist's pinned ``HERMES_HOME`` — never from a unit label or an argv
    +substring. Unknown ownership is left alone: a restart we cannot prove is ours is somebody else's
    +outage.
    +"""
    +
    +from __future__ import annotations
    +
    +import shlex
    +from contextlib import suppress
    +from pathlib import Path
    +
    +
    +def _resolved(path) -> Path | None:
    +    try:
    +        return Path(str(path)).expanduser().resolve()
    +    except (OSError, RuntimeError, ValueError):
    +        return None
    +
    +
    +def update_scope_homes() -> set[Path]:
    +    """Resolved homes the running update owns: the invoking home, the install root and its profiles."""
    +    homes: set[Path] = set()
    +    with suppress(Exception):
    +        from hermes_constants import get_hermes_home
    +        if (home := _resolved(get_hermes_home())) is not None:
    +            homes.add(home)
    +    with suppress(Exception):
    +        from hermes_cli.update_receipt import _profile_homes
    +        for _profile, home in _profile_homes():
    +            if (resolved := _resolved(home)) is not None:
    +                homes.add(resolved)
    +    return homes
    +
    +
    +def home_in_update_scope(home, scope: set[Path] | None = None) -> bool:
    +    """True when *home* (a path or string) is one of the homes this update owns."""
    +    if not home:
    +        return False
    +    resolved = _resolved(home)
    +    if resolved is None:
    +        return False
    +    return resolved in (update_scope_homes() if scope is None else scope)
    +
    +
    +def gateway_pid_in_update_scope(pid: int, scope: set[Path] | None = None) -> bool | None:
    +    """Does gateway *pid* run on a home this update owns? ``None`` when its home cannot be read."""
    +    from hermes_cli.dashboard_procs import _hermes_home_for_pid
    +    try:
    +        home = _hermes_home_for_pid(pid)
    +    except Exception:
    +        return None
    +    if home is None:
    +        return None
    +    return home_in_update_scope(home, scope)
    +
    +
    +def partition_gateway_pids_by_scope(pids, scope: set[Path] | None = None) -> tuple[list[int], list[tuple[int, str | None]]]:
    +    """``(owned, foreign)`` split of *pids*; ``foreign`` pairs each PID with its home (None = unreadable)."""
    +    scope = update_scope_homes() if scope is None else scope
    +    owned: list[int] = []
    +    foreign: list[tuple[int, str | None]] = []
    +    for pid in pids:
    +        verdict = gateway_pid_in_update_scope(pid, scope)
    +        if verdict:
    +            owned.append(pid)
    +        else:
    +            home = None
    +            if verdict is False:
    +                with suppress(Exception):
    +                    from hermes_cli.dashboard_procs import _hermes_home_for_pid
    +                    home = _hermes_home_for_pid(pid)
    +            foreign.append((pid, home))
    +    return owned, foreign
    +
    +
    +def systemd_unit_hermes_home(scope_cmd: list, svc_name: str) -> str | None:
    +    """Home the systemd unit *svc_name* runs on: its live MainPID's environment first, then the
    +    unit's declared ``Environment=HERMES_HOME``; for a user-scope unit that declares none, the
    +    user's own default home. ``None`` when nothing readable names a home."""
    +    from hermes_cli.update_cmd_fleet import _systemctl, _unit_main_pid
    +
    +    pid = _unit_main_pid(scope_cmd, svc_name)
    +    if pid > 0:
    +        with suppress(Exception):
    +            from hermes_cli.dashboard_procs import _hermes_home_for_pid
    +            if (home := _hermes_home_for_pid(pid)) is not None:
    +                return home
    +    try:
    +        shown = _systemctl(list(scope_cmd) + ["show", svc_name, "--property=Environment", "--value"], timeout=10)
    +    except Exception:
    +        return None
    +    if getattr(shown, "returncode", 1) != 0:
    +        return None
    +    env_line = (getattr(shown, "stdout", "") or "").strip()
    +    try:
    +        tokens = [redacted:secret-assignment]
    +    except ValueError:
    +        tokens = env_line.split()
    +    for token in tokens:
    +        key, sep, value = token.partition("=")
    +        if sep and key == "HERMES_HOME" and value.strip():
    +            return value.strip()
    +    if "--user" in scope_cmd:
    +        return str(Path.home() / ".hermes")
    +    return None
    +
    +
    +def systemd_unit_in_update_scope(scope_cmd: list, svc_name: str, scope: set[Path] | None = None) -> bool | None:
    +    """Does unit *svc_name* belong to this update? ``None`` = ownership unreadable (leave it alone)."""
    +    home = systemd_unit_hermes_home(scope_cmd, svc_name)
    +    if home is None:
    +        return None
    +    return home_in_update_scope(home, scope)
    +
    +
    +def launchd_label_foreign_home(label: str, scope: set[Path] | None = None) -> str | None:
    +    """The HERMES_HOME a derived launchd label's installed plist pins when that home is NOT one of
    +    this update's — labels are account-global, so root B's default profile derives the same bare
    +    ``ai.hermes.gateway`` root A installed. ``None`` = ours, or no/unreadable plist (the locate step
    +    decides whether a job exists; only a proven foreign home is refused)."""
    +    import plistlib
    +    with suppress(Exception):
    +        from hermes_cli.gateway import get_launchd_plist_path
    +        plist_path = get_launchd_plist_path().with_name(f"{label}.plist")
    +        if not plist_path.exists():
    +            return None
    +        data = plistlib.loads(plist_path.read_bytes())
    +        pinned = str(data["EnvironmentVariables"]["HERMES_HOME"])
    +        return None if home_in_update_scope(pinned, scope) else pinned
    +    return None
    +
    +
    +def describe_skipped_runtime(kind: str, name: str, home: str | None) -> str:
    +    """One notice line for a runtime the update leaves alone (foreign home or unreadable ownership)."""
    +    if home is None:
    +        return f"  ↷ {name}: {kind} whose Hermes home could not be read — left alone (not restarted)"
    +    return f"  ↷ {name}: {kind} of another Hermes home ({home}) — left alone (not restarted)"
    ```
  - `hermes_cli/update_handoff.py`

    ```diff
    @@ -183,6 +183,17 @@ def _print_manual_continuation(cmd: list[str], exc: OSError) -> None:
         print(f"    {subprocess.list2cmdline(cmd)}")
     
     
    +def _post_swap_cwd() -> str:
    +    """Directory the child must start in so a stale editable finder can still import.
    +
    +    ``python -m`` puts the process cwd on ``sys.path[0]``, not the checkout. Invoking
    +    ``hermes update`` from elsewhere then resolves new top-level names only through the
    +    installed finder. If that map is stale, the child dies in ``import hermes_cli.main``
    +    before the dependency sync can refresh it (#119466).
    +    """
    +    return str(Path(__file__).resolve().parent.parent)
    +
    +
     def continue_update_in_fresh_interpreter(payload: dict[str, Any], *, argv_tail: list[str]) -> int | None:
         """Run the post-swap tail in a child interpreter on the pulled code.
     
    ```

    ```diff
    @@ -201,13 +212,15 @@ def continue_update_in_fresh_interpreter(payload: dict[str, Any], *, argv_tail:
         handoff_path = write_handoff(payload)
         cmd = post_swap_command(handoff_path, argv_tail)
         env = post_swap_child_env()
    +    cwd = _post_swap_cwd()
         logger.debug("Post-swap hand-off → %s", subprocess.list2cmdline(cmd))
         sys.stdout.flush()
         sys.stderr.flush()
     
         if _running_from_windows_shim():
             try:
    -            subprocess.Popen(cmd, env=detached_shim_child_env(env), stdin=subprocess.DEVNULL)
    +            subprocess.Popen(
    +                cmd, env=detached_shim_child_env(env), stdin=subprocess.DEVNULL, cwd=cwd)
             except OSError as exc:
                 _print_manual_continuation(cmd, exc)
                 return None
    ```

    ```diff
    @@ -217,7 +230,7 @@ def continue_update_in_fresh_interpreter(payload: dict[str, Any], *, argv_tail:
             return 0
     
         try:
    -        child = subprocess.Popen(cmd, env=env, stdin=sys.stdin)
    +        child = subprocess.Popen(cmd, env=env, stdin=sys.stdin, cwd=cwd)
         except OSError as exc:
             _print_manual_continuation(cmd, exc)
             return None
    ```
  - `hermes_cli/update_receipt.py`

    ```diff
    @@ -385,6 +385,9 @@ def _gateway_code_root(pid: int, home: Path) -> Optional[Path]:
     
     
     EXTERNAL_STATE = "external"
    +# A gateway this updater runs INSIDE that accepted a self-restart request: it is still on the
    +# pre-update code by construction and restarts once the updater exits (#100179 / #119597).
    +RESTART_PENDING_STATE = "restart_pending"
     
     
     def row_is_external(row: Any) -> bool:
    ```

    ```diff
    @@ -396,11 +399,14 @@ def _fleet_row(
         profile: str, pid: int, code_sha: Any, code_version: Any, expected_sha: Any,
         state: str = "unknown", code_root: Optional[Path] = None,
         expected_root: Optional[Path] = None, served_profiles: Any = None,
    +    self_restart_pending: Optional[set] = None,
     ) -> dict[str, Any]:
         if state == "unknown" and code_root and expected_root and code_root != expected_root:
             state = EXTERNAL_STATE
         if state == "unknown" and code_sha and expected_sha:
             state = "current" if str(code_sha) == str(expected_sha) else "stale"
    +    if state == "stale" and self_restart_pending and pid in self_restart_pending:
    +        state = RESTART_PENDING_STATE
         row = {
             "profile": profile, "pid": pid, "code_sha": str(code_sha) if code_sha else None,
             "code_version": code_version, "state": state,
    ```

    ```diff
    @@ -421,9 +427,17 @@ def _fleet_row(
     _NOT_EXPECTED_STATES = {"stopped", "startup_failed"}
     
     
    -def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> list[dict[str, Any]]:
    +def collect_fleet_versions(
    +    *, pre_restart_pids: Optional[list[int]] = None, self_restart_pending: Optional[set] = None,
    +) -> list[dict[str, Any]]:
         """Snapshot every profile's gateway code identity vs. the current tree.
     
    +    ``self_restart_pending`` — pids of gateways that are ancestors of this updater and accepted a
    +    self-restart request (cron update inside the gateway tree, #100179). They can only restart after
    +    this process exits, so their pre-update ``code_sha`` is expected: such a row is
    +    ``restart_pending`` instead of ``stale`` and does not fail the matrix (#119597). Every other
    +    live gateway on the old sha keeps its ``stale`` verdict.
    +
         Rollout safety: ``down`` requires membership in ``pre_restart_pids`` — a stale state file from a
         long-dead gateway (machine reboot, manual kill weeks ago) must NOT fail every future update.
         Without a pre-restart snapshot (``None``/empty) dead PIDs are skipped (historical behavior).
    ```

    ```diff
    @@ -440,6 +454,7 @@ def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> l
         verification gap, #88848/#74973 class).
         """
         _pre_restart = {int(p) for p in (pre_restart_pids or []) if isinstance(p, int)}
    +    _pending = {int(p) for p in (self_restart_pending or ()) if isinstance(p, int)}
         results: list[dict[str, Any]] = []
         expected_sha = _code_identity(refresh=True).get("sha")
         expected_root = _updater_code_root()
    ```

    ```diff
    @@ -458,6 +473,7 @@ def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> l
                         profile, pid, identity.get("code_sha"), identity.get("code_version"), expected_sha,
                         served_profiles=identity.get("served_profiles"),
                         code_root=_gateway_code_root(pid, home), expected_root=expected_root,
    +                    self_restart_pending=_pending,
                     )
                     results.append({**row, "source": "socket"})
                     continue
    ```

    ```diff
    @@ -477,6 +493,7 @@ def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> l
                             profile, pid, record.get("code_sha"), record.get("code_version"), expected_sha,
                             served_profiles=record.get("served_profiles"),
                             code_root=_gateway_code_root(pid, home), expected_root=expected_root,
    +                        self_restart_pending=_pending,
                         )
                     )
                     continue
    ```

    ```diff
    @@ -508,6 +525,10 @@ def collect_fleet_versions(*, pre_restart_pids: Optional[list[int]] = None) -> l
         "stale": "  ✗ {profile} (pid {pid}) @ {short} — STALE (pre-update code)",
         "down": "  ✗ {profile} — DOWN (gateway was running before the update; pid {pid} is gone and nothing replaced it)",
         "external": "  ◆ {profile} (pid {pid}) @ {short} — separate checkout, not updated by this run",
    +    RESTART_PENDING_STATE: (
    +        "  ↻ {profile} (pid {pid}) @ {short} — restart pending (deferred until this process exits;"
    +        " the update runs inside this gateway)"
    +    ),
     }
     _FLEET_ROW_UNKNOWN = "  ? {profile} (pid {pid}) — version unknown (gateway predates version stamping; restart to enable)"
     # A gateway pid the pre-update snapshot did not know that had not published its code identity when
    ```

    ```diff
    @@ -527,7 +548,8 @@ def print_fleet_version_matrix(fleet: list[dict[str, Any]]) -> bool:
         provably down (killed by the restart phase, nothing came back), so the caller can escalate.
         ``unknown`` entries are reported but do NOT fail the update: gateways started before the
         code-identity stamp existed have no sha to compare, and failing them would be a false-positive
    -    storm.
    +    storm. ``restart_pending`` entries (the gateway this updater runs inside, self-restart
    +    accepted) are on the old code by construction and do not fail it either (#119597).
         """
         if not fleet:
             return False
    ```

    ```diff
    @@ -549,6 +571,10 @@ def print_fleet_version_matrix(fleet: list[dict[str, Any]]) -> bool:
             print("  ℹ These profiles run their own checkout and are updated separately:")
             for line in external_roots:
                 print(f"      {line}")
    +    if RESTART_PENDING_STATE in states:
    +        print()
    +        print("  ℹ A restart-pending gateway picks up the new code as soon as this update exits;")
    +        print("    verify afterwards with `hermes gateway status`.")
         stale_or_down = sum(1 for entry in fleet if entry.get("state") in ("stale", "down"))
         if stale_or_down:
             print()
    ```
  - `hermes_cli/web_routers/sessions.py`

    ```diff
    @@ -744,7 +744,7 @@ def _update(db):
             result["title"] = db.get_session_title(sid) or ""
             return result
     
    -    return _with_db(body.profile, _update, read_only=False)
    +    return await asyncio.to_thread(_with_db, body.profile, _update, read_only=False)
     
     
     def _compact_json(obj) -> str:
    ```
  - `hermes_state_sessions.py`

    ```diff
    @@ -667,15 +667,20 @@ def update_session_tool_names(self, session_id: str, tool_names: Optional[List[s
             payload = json.dumps(list(tool_names)) if tool_names is not None else None
             self._write_sql("UPDATE sessions SET tool_names = ? WHERE id = ?", (payload, session_id))
     
    -    def update_session_model(self, session_id: str, model: str, provider: Optional[str] = None) -> None:
    +    def update_session_model(
    +        self, session_id: str, model: str, provider: Optional[str] = None, *,
    +        base_url: Optional[str] = None, api_mode: Optional[str] = None,
    +    ) -> None:
             """Set the model after a mid-session /model switch (unconditionally), null system_prompt so
             stale Model:/Provider: footers rebuild, and drop any Browser runtime lock (lineage markers
    -        survive). *provider* is merged into model_config so resume recombines model and provider.
    -
    -        When *provider* is given, it is merged into ``model_config`` alongside the model (``$.model`` /
    -        ``$.provider``) so a later resume recombines the persisted model with the provider that actually
    -        serves it instead of the config.yaml primary provider (#79536). Callers without provider knowledge
    -        leave any stored provider untouched.
    +        survive).
    +
    +        When *provider* is given the whole route is written, in both shapes resume reads (top-level
    +        keys for the TUI/Desktop, ``gateway_runtime`` for the CLI), so a later resume recombines the
    +        model with the provider that serves it (#79536). ``base_url``/``api_mode`` are always
    +        replaced then (``None`` deletes): the previous provider's endpoint must not survive a switch,
    +        or resume sends the new provider's model to the old host. Callers without provider knowledge
    +        leave the stored route untouched.
             """
             # Flush first: a still-queued pre-switch delta applied after this UPDATE would trip the
             # first_accounted_route overwrite and resurrect the old route.
    ```

    ```diff
    @@ -684,7 +689,8 @@ def update_session_model(self, session_id: str, model: str, provider: Optional[s
             if model:
                 patch["model"] = model
             if provider:
    -            patch["provider"] = provider
    +            route = {"provider": provider, "base_url": base_url or None, "api_mode": api_mode or None}
    +            patch.update(route, gateway_runtime=route)
             self._write_model_config_patch(
                 session_id, patch, "UPDATE sessions SET model = ?, model_config = ?, "
                 "system_prompt = NULL, system_prompt_hash = NULL WHERE id = ?",
    ```
  - `plugins/memory/holographic/__init__.py`

    ```diff
    @@ -21,6 +21,10 @@
     
     logger = logging.getLogger(__name__)
     
    +# Expanded by initialize() against the profile that opens it. A concrete path is copied by a profile clone
    +# and outlives a rename, so it keeps naming the old profile's DB.
    +_DEFAULT_DB_PATH = "$HERMES_HOME/memory_store.db"
    +
     
     FACT_STORE_SCHEMA = {
         "name": "fact_store",
    ```

    ```diff
    @@ -116,12 +120,17 @@ def save_config(self, values, hermes_home):
             # The canonical writer: config lock, managed-mode refusal, default stripping, atomic replace.
             # ``merge_existing`` keeps every other section; *hermes_home* is the active profile already.
             from hermes_cli.config import save_config
    -        save_config({"plugins": {"hermes-memory-store": dict(values)}}, merge_existing=True)
    +        values = dict(values)
    +        # This profile's own DB spelled out (older setups wrote it; the dashboard form re-submits what it
    +        # read) pins every clone and rename of the profile to this file, so it is stored as the placeholder.
    +        db_path = values.get("db_path")
    +        if isinstance(db_path, str) and Path(db_path).expanduser() == Path(hermes_home) / "memory_store.db":
    +            values["db_path"] = _DEFAULT_DB_PATH
    +        save_config({"plugins": {"hermes-memory-store": values}}, merge_existing=True)
     
         def get_config_schema(self):
    -        from hermes_constants import display_hermes_home
             return [
    -            {"key": "db_path", "description": "SQLite database path", "default": f"{display_hermes_home()}/memory_store.db"},
    +            {"key": "db_path", "description": "SQLite database path", "default": _DEFAULT_DB_PATH},
                 {"key": "auto_extract", "description": "Auto-extract facts at session end", "default": "false", "choices": ["true", "false"]},
                 {"key": "default_trust", "description": "Default trust score for new facts", "default": "0.5"},
                 {"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"},
    ```
  - `run_agent.py`

    ```diff
    @@ -289,7 +289,7 @@ def __init__(
             checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10,
             pass_session_id: bool = False, requested_provider: str = None,
             capabilities: Dict[str, bool] | None = None, cwd: str | None = None,
    -        side_agent: bool = False,
    +        side_agent: bool = False, memory_manager=None,
         ):
             """Forwarder — see ``agent.agent_init.init_agent`` (same keyword parameters, minus ``tool_delay``)."""
             init_kwargs = {k: v for k, v in locals().items() if k not in ("self", "tool_delay")}
    ```
  - `scripts/desktop-update/windows.ps1`

    ```diff
    @@ -1198,6 +1198,8 @@ function Resolve-HermesVenvDir([string]$Root) {
     }
     
     $finalCode = 1
    +$manualAction = $false
    +$manualMsg = ""
     $finalMsg = "update did not complete"
     $script:TreeSafeToFinalize = $true
     
    ```

    ```diff
    @@ -1679,6 +1681,25 @@ try {
             }
         }
     
    +    # Desktop stopped every locally running profile gateway before handing off
    +    # so their venv launchers could not hold the update lock. That happens
    +    # before `hermes update` captures its Windows pause inventory, leaving the
    +    # updater nothing to resume on its normal success path. Restore the same
    +    # all-profile fleet only after the updated runtime verifies. A remote-served
    +    # Desktop must stay passive: its -NoGateway hand-off owns no local poller.
    +    if ($res.Code -eq 0 -and -not $desktopBuildFailed -and -not $NoGateway) {
    +        $gatewayRestart = Invoke-HermesStep $pythonExe @("-m", "hermes_cli.main", "gateway", "start", "--all") "gateway restart"
    +        if ($gatewayRestart.Code -ne 0) {
    +            # The update itself succeeded; a restart miss is a manual follow-up
    +            # (Write-Result's manual flag -> Desktop boot dialog), never a failed
    +            # update: a non-zero exit here would run the error finale and hide
    +            # the fact that the new runtime is installed and verified.
    +            $manualAction = $true
    +            $manualMsg = "Update complete, but Hermes could not restart every messaging gateway. Run `hermes gateway start --all` in a terminal."
    +            Write-HandoffLog $manualMsg
    +        }
    +    }
    +
         if ($res.Code -eq 0 -and -not $desktopBuildFailed) {
             $finalCode = 0
             $finalMsg = "Update complete."
    ```

    ```diff
    @@ -1710,7 +1731,8 @@ try {
             Show-ErrorFinale $finalMsg
             Close-ProgressWindow
         } else {
    -        Write-Result ($finalCode -eq 0) $finalCode $finalMsg
    +        if ($finalCode -eq 0 -and $manualAction) { $finalMsg = $manualMsg }
    +        Write-Result ($finalCode -eq 0) $finalCode $finalMsg ($finalCode -eq 0 -and $manualAction)
             Remove-MarkerIfOwned
             if ($finalCode -ne 0) {
                 Show-ErrorFinale $finalMsg
    ```
  - `tests/agent/test_failed_turn_site_codes.py`

    ```diff
    @@ -38,7 +38,8 @@ def test_overflow_exhaustion_is_non_retryable_context_overflow_with_slash_comman
         assert build_error_surface_from_result(result)["code"] == "context_overflow"
     
     
    -def _context_rejection(request_tokens: int, window: int = 65_536, error="HTTP 500: Context size has been exceeded."):
    +def _context_rejection(request_tokens: int, window: int = 65_536, error="HTTP 500: Context size has been exceeded.",
    +                       base_url: str = "http://127.0.0.1:1234/v1"):
         """Drive ``_recover_context_length`` with a provider "context exceeded" and a request the rough
         estimator prices at ``request_tokens`` against a ``window``-token model (no output cap)."""
         from unittest.mock import patch
    ```

    ```diff
    @@ -50,7 +51,7 @@ def _context_rejection(request_tokens: int, window: int = 65_536, error="HTTP 50
         st.compression_attempts = 0
         st.agent.max_tokens = None
         st.agent.context_compressor = SimpleNamespace(context_length=window)
    -    st.agent.provider, st.agent.base_url, st.agent.tools = "lmstudio", "http://127.0.0.1:1234/v1", None
    +    st.agent.provider, st.agent.base_url, st.agent.tools = "lmstudio", base_url, None
         st.agent._buffer_vprint = st.agent._buffer_diagnostic_status = lambda *a, **k: None
         compressed = []
         st.agent._compress_context = lambda msgs, *a, **k: (compressed.append(1) or [{"role": "user", "content": "x"}], None)
    ```

    ```diff
    @@ -87,6 +88,14 @@ def test_context_rejection_near_the_window_still_compresses():
         assert compressed and verdict.action == "break"
     
     
    +def test_hosted_context_rejection_far_below_the_known_window_compresses():
    +    """A hosted route has no shared slot to wait out: a small request rejected there means the
    +    route's real window is below the one Hermes assumes, so /retry would fail forever. Compress."""
    +    verdict, compressed = _context_rejection(
    +        47_000, window=1_000_000, base_url="https://api.anthropic.com",
    +        error="This model's maximum context length was exceeded. Please reduce the length of the messages.",
    +    )
    +    assert compressed and verdict.action == "break"
     
     
     def test_empty_response_exhaustion_has_one_text_everywhere():
    ```
  - `tests/agent/test_memory_provider_init.py`

    ```diff
    @@ -170,3 +170,34 @@ def test_core_tool_names_rejected_from_memory_routing_table():
         assert "honcho_search" in schema_names
     
     
    +
    +
    +def test_aiagent_reuses_handed_in_memory_manager_without_reinitializing():
    +    """A caller that rebuilds the agent per turn (gateway api_server) hands back the session's manager:
    +    the provider keeps its state — no second load, no second initialize (#120116)."""
    +    provider = RecordingMemoryProvider()
    +    cfg = {"memory": {"provider": "recording"}, "agent": {}}
    +    common = dict(
    +        api_key="[redacted:secret-assignment]", base_url="https://openrouter.ai/api/v1", quiet_mode=True,
    +        skip_context_files=True, skip_memory=False, session_id="sess-api", platform="api_server",
    +    )
    +    with (
    +        patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg),
    +        patch("plugins.memory.load_memory_provider", return_value=provider) as load_memory_provider,
    +        patch("agent.model_metadata.get_model_context_length", return_value=204_800),
    +        patch("model_tools.get_tool_definitions", return_value=[]),
    +        patch("model_tools.check_toolset_requirements", return_value={}),
    +        patch("agent.process_bootstrap.OpenAI"),
    +    ):
    +        from run_agent import AIAgent
    +
    +        first = AIAgent(**common)
    +        manager = first._memory_manager
    +        assert manager is not None and load_memory_provider.call_count == 1
    +        provider.init_session_id = None  # a re-initialize would set it again
    +
    +        second = AIAgent(memory_manager=manager, **common)
    +
    +    assert second._memory_manager is manager
    +    assert load_memory_provider.call_count == 1
    +    assert provider.init_session_id is None
    ```
  - `tests/agent/test_mid_turn_compaction_turn_boundary.py`

    ```diff
    @@ -0,0 +1,154 @@
    +from types import SimpleNamespace
    +from unittest.mock import MagicMock, patch
    +
    +from agent.turn_preflight import compress_after_tool_results
    +from run_agent import AIAgent
    +
    +
    +def test_post_tool_compression_reanchors_the_active_user_boundary(monkeypatch):
    +    compressed = [
    +        {"role": "user", "content": "compressed history"},
    +        {"role": "user", "content": "current ask"},
    +        {"role": "assistant", "tool_calls": [{"id": "2"}]},
    +        {"role": "tool", "content": "fresh result", "tool_call_id": "2"},
    +    ]
    +
    +    class Compressor:
    +        last_prompt_tokens = 100
    +        threshold_tokens = 50
    +
    +        @staticmethod
    +        def should_compress(_tokens):
    +            return True
    +
    +    agent = SimpleNamespace(
    +        context_compressor=Compressor(),
    +        compression_enabled=True,
    +        _clear_context_overflow_warn=lambda: None,
    +        _safe_print=lambda *_args: None,
    +        _compress_context=lambda *_args, **_kwargs: (compressed, "system"),
    +        _persist_user_message_idx=4,
    +    )
    +    monkeypatch.setattr(
    +        "agent.turn_preflight.conversation_history_after_compression",
    +        lambda _agent, _messages, _history: [],
    +    )
    +    monkeypatch.setattr(
    +        "agent.conversation_loop._should_skip_model_call_for_reference_handoff",
    +        lambda _messages, _user_message: False,
    +    )
    +
    +    verdict = compress_after_tool_results(
    +        agent,
    +        messages=[{"role": "user", "content": "current ask"}],
    +        system_message="system",
    +        user_message="current ask",
    +        active_system_prompt="system",
    +        conversation_history=[],
    +        compression_attempts=0,
    +        max_compression_attempts=1,
    +        effective_task_id="task",
    +        final_response="",
    +        turn_exit_reason=None,
    +        current_turn_user_idx=0,
    +    )
    +
    +    assert verdict.messages is compressed
    +    assert verdict.current_turn_user_idx == 1
    +    assert agent._persist_user_message_idx == 1
    +
    +
    +def _response(*, tool: bool):
    +    tool_calls = [SimpleNamespace(
    +        id="call_1", type="function",
    +        function=SimpleNamespace(name="web_search", arguments='{"query": "x"}'),
    +    )] if tool else None
    +    message = SimpleNamespace(
    +        content=None if tool else "done", reasoning_content=None, reasoning=None, tool_calls=tool_calls,
    +    )
    +    return SimpleNamespace(
    +        choices=[SimpleNamespace(message=message, finish_reason="tool_calls" if tool else "stop")],
    +        model="test/model", usage=None,
    +    )
    +
    +
    +def test_pre_api_compression_mid_turn_keeps_this_turns_tool_pair_on_the_wire():
    +    """Pre-API compression after a tool round shrinks ``messages`` from the front; the next request
    +    must still carry this turn's assistant tool_call and its result (state.db holds both)."""
    +    tool_def = {"type": "function", "function": {
    +        "name": "web_search", "description": "s",
    +        "parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
    +    }}
    +    with (
    +        patch("model_tools.get_tool_definitions", return_value=[tool_def]),
    +        patch("model_tools.check_toolset_requirements", return_value={}),
    +        patch("agent.process_bootstrap.OpenAI"),
    +        patch("agent.model_metadata.get_model_context_length", return_value=256_000),
    +        patch("agent.context_compressor.get_model_context_length", return_value=256_000),
    +    ):
    +        agent = AIAgent(
    +            api_key="[redacted:secret-assignment]", base_url="https://openrouter.ai/api/v1", model="test/model",
    +            quiet_mode=True, skip_context_files=True, skip_memory=True, max_iterations=6,
    +        )
    +    agent.client = MagicMock()
    +    # No usage: the post-tool gate has no real count, so the pre-API gate owns the compaction.
    +    agent.client.chat.completions.create.side_effect = [_response(tool=True), _response(tool=False)]
    +    agent._cached_system_prompt = "You are helpful."
    +    agent._use_prompt_caching = False
    +    agent._disable_streaming = True
    +    agent.tool_delay = 0
    +    agent.save_trajectories = False
    +
    +    compressor = MagicMock()
    +    compressor.protect_first_n = 3
    +    compressor.protect_last_n = 20
    +    compressor.threshold_tokens = 100
    +    compressor.context_length = 1_000
    +    compressor.last_prompt_tokens = -1
    +    compressor._verify_compaction_cleared_threshold = False
    +    compressor.awaiting_real_usage_after_compression = False
    +    compressor.should_compress.side_effect = lambda tokens: tokens >= 100
    +    compressor.should_compress_info.return_value = (False, None)
    +    compressor.should_compress_preflight.return_value = False
    +    compressor.should_defer_preflight_to_real_usage.return_value = False
    +    compressor.get_active_compression_failure_cooldown.return_value = None
    +    compressor.select_context.return_value = None
    +    compressor.get_automatic_compaction_status_message.return_value = ""
    +    agent.compression_enabled = True
    +    agent.context_compressor = compressor
    +
    +    compactions = []
    +
    +    def _estimate(messages=None, *_args, **_kwargs):
    +        # The tool result tips the request over threshold until one compaction ran.
    +        return 200 if not compactions and any(m.get("role") == "tool" for m in messages or []) else 10
    +
    +    def _compress(messages, _system_message, **_kwargs):
    +        compactions.append(len(messages))
    +        return list(messages[2:]), "compressed prompt"  # two historical rows summarized away
    +
    +    def _execute(_assistant_message, messages, *_args):
    +        messages.append({"role": "tool", "name": "web_search", "tool_call_id": "call_1", "content": "RESULT-1"})
    +
    +    history = [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(30)]
    +    with (
    +        patch("agent.turn_context.estimate_request_tokens_rough", return_value=10),
    +        patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
    +        patch("agent.conversation_loop._estimate_tools_tokens_rough", return_value=0),
    +        patch.object(agent, "_compress_context", side_effect=_compress),
    +        patch.object(agent, "_execute_tool_calls", side_effect=_execute),
    +        patch.object(agent, "_flush_messages_to_session_db", return_value=True),
    +        patch.object(agent, "_persist_session"),
    +        patch.object(agent, "_save_trajectory"),
    +        patch.object(agent, "_cleanup_task_resources"),
    +    ):
    +        result = agent.run_conversation("do tool work", conversation_history=history)
    +
    +    assert result["final_response"] == "done"
    +    assert len(compactions) == 1
    +    sent = agent.client.chat.completions.create.call_args_list[-1].kwargs["messages"]
    +    assert [(m["role"], m.get("tool_call_id")) for m in sent[-3:]] == [
    +        ("user", None), ("assistant", None), ("tool", "call_1"),
    +    ]
    +    assert [t["id"] for t in sent[-2]["tool_calls"]] == ["call_1"]
    +    assert sent[-1]["content"] == "RESULT-1"
    ```
  - `tests/agent/test_proactive_prune_loop_wiring.py`

    ```diff
    @@ -154,6 +154,7 @@ def test_pending_checkpoint_waits_without_warning_or_pruning(self, agent):
                     active_system_prompt="system", conversation_history=[],
                     compression_attempts=0, max_compression_attempts=3,
                     effective_task_id=None, final_response="", turn_exit_reason=None,
    +                current_turn_user_idx=0,
                 )
             assert verdict.messages is messages
             assert not verdict.end_turn
    ```
  - `tests/agent/test_relay_session_segments.py`

    ```diff
    @@ -17,7 +17,11 @@
     
     from __future__ import annotations
     
    +import os
    +import subprocess
    +import sys
     import threading
    +from pathlib import Path
     from typing import Any
     
     import pytest
    ```

    ```diff
    @@ -29,6 +33,19 @@
     )
     
     
    +def _run_isolated(code: str) -> subprocess.CompletedProcess[str]:
    +    """Run a Python snippet in the repo root (not tests/) in a fresh process."""
    +    repo_root = Path(__file__).parent.parent.parent
    +    return subprocess.run(
    +        [sys.executable, "-c", code],
    +        capture_output=True,
    +        text=True,
    +        cwd=str(repo_root),
    +        env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
    +        timeout=30,
    +    )
    +
    +
     class _ScopeHandle:
         def __init__(self, name: str, seq: int) -> None:
             self.name = name
    ```

    ```diff
    @@ -121,15 +138,15 @@ def _fast_scope_timeout(monkeypatch):
     def _default_config(monkeypatch):
         """No config on disk by default; tests override _segments_config directly."""
         monkeypatch.setattr(
    -        "gateway.run._load_gateway_config", lambda: {}, raising=False
    +        "hermes_cli.config_effective.load_user_config_effective", lambda *_a, **_k: {}
         )
         relay_runtime._reset_segments_config_for_tests()
     
     
     def _set_segments(monkeypatch, *, on_compaction=False, max_turns=0):
         monkeypatch.setattr(
    -        "gateway.run._load_gateway_config",
    -        lambda: {
    +        "hermes_cli.config_effective.load_user_config_effective",
    +        lambda *_a, **_k: {
                 "gateway": {
                     "telemetry": {
                         "session_segments": {
    ```

    ```diff
    @@ -139,7 +156,6 @@ def _set_segments(monkeypatch, *, on_compaction=False, max_turns=0):
                     }
                 }
             },
    -        raising=False,
         )
         relay_runtime._reset_segments_config_for_tests()
     
    ```

    ```diff
    @@ -492,3 +508,34 @@ def test_subagent_children_parent_to_new_segment_after_rotation(
             assert child_push["parent"] is new_handle, (
                 "post-rotation children must parent to the new segment handle"
             )
    +
    +
    +class TestGatewayRunStaysUnimported:
    +    """Guard against re-importing gateway.run from a non-gateway host.
    +
    +    relay_runtime._segments_config() must NEVER trigger ``gateway.run`` — its
    +    import-time env setup (_HERMES_GATEWAY, HERMES_QUIET, TERMINAL_CWD := home)
    +    hangs CLI approvals (#87183) and runs ``hermes -z`` in $HOME (#95577). A
    +    monkeypatch can't catch a refactor re-adding the import, so this runs the
    +    real path in a fresh process with those vars unset.
    +    """
    +
    +    def test_relay_runtime_never_imports_gateway_run(self, monkeypatch) -> None:
    +        for var in ("TERMINAL_CWD", "HERMES_QUIET", "_HERMES_GATEWAY"):
    +            monkeypatch.delenv(var, raising=False)
    +        result = _run_isolated(
    +            """
    +import os
    +import sys
    +
    +import agent.relay_runtime as rr
    +
    +rr._segments_config()
    +rr._segments_config()  # cached path too
    +
    +leaked = {v: os.environ[v] for v in ("TERMINAL_CWD", "HERMES_QUIET", "_HERMES_GATEWAY") if v in os.environ}
    +print("gateway.run imported:", "gateway.run" in sys.modules, "leaked env:", leaked)
    +sys.exit(1 if "gateway.run" in sys.modules or leaked else 0)
    +"""
    +        )
    +        assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
    ```
  - `tests/gateway/test_api_server_memory_sessions.py`

    ```diff
    @@ -0,0 +1,43 @@
    +"""api_server keeps one memory provider per session across requests (#120116)."""
    +
    +from types import SimpleNamespace
    +
    +from gateway.platforms.api_server_memory_sessions import ApiServerMemorySessions
    +
    +
    +class _Manager:
    +    def __init__(self):
    +        self.shut_down = False
    +
    +    def flush_pending(self, timeout=None):
    +        return True
    +
    +    def shutdown_all(self):
    +        self.shut_down = True
    +
    +
    +def _agent(manager, session_id="sess-1"):
    +    return SimpleNamespace(_memory_manager=manager, session_id=session_id)
    +
    +
    +def test_manager_survives_across_requests_and_is_exclusive_per_home(monkeypatch):
    +    registry = ApiServerMemorySessions(max_size=8, idle_ttl_secs=3600.0)
    +    home = ["home-a"]
    +    monkeypatch.setattr(ApiServerMemorySessions, "_owner_home", staticmethod(lambda: (home[0], None)))
    +    # Shutdowns run inline so the assertion below does not race a daemon thread.
    +    monkeypatch.setattr(registry, "_shutdown_async", registry._shutdown)
    +
    +    assert registry.checkout("sess-1") is None  # first request builds its own
    +    manager = _Manager()
    +    registry.checkin(_agent(manager))
    +    assert registry.checkout("sess-1") is manager  # next request on the session gets it back
    +    assert registry.checkout("sess-1") is None  # ...exclusively: a concurrent request builds a new one
    +    loser = _Manager()
    +    registry.checkin(_agent(manager))
    +    registry.checkin(_agent(loser))  # the concurrent request checks in behind the winner
    +    assert manager.shut_down and not loser.shut_down
    +    # Same session id under another profile home is a different key (#120116 x multiplex).
    +    home[0] = "home-b"
    +    assert registry.checkout("sess-1") is None
    +    home[0] = "home-a"
    +    assert registry.checkout("sess-1") is loser
    ```
  - `tests/gateway/test_config.py`

    ```diff
    @@ -255,7 +255,62 @@ def test_email_can_opt_into_pairing_for_unauthorized_dm_behavior(self):
     
     
     class TestLoadGatewayConfig:
    +    def test_platforms_env_refs_expanded_for_adapters(self, tmp_path, monkeypatch):
    +        """``${VAR}`` refs under ``platforms:`` reach the adapter config expanded — the gateway
    +        YAML layer expands them the same way the CLI loader does (webhook secret used as the
    +        HMAC key; api_server caller-auth key)."""
    +        hermes_home = tmp_path / ".hermes"
    +        hermes_home.mkdir()
    +        (hermes_home / "config.yaml").write_text(
    +            "platforms:\n"
    +            "  webhook:\n"
    +            "    enabled: true\n"
    +            "    port: 8089\n"
    +            "    secret: ${WEBHOOK_SECRET}\n"
    +            "    path_prefix: ${HOOK_PREFIX_FOR_TEST}\n"
    +            "  api_server:\n"
    +            "    enabled: true\n"
    +            "    key: ${env:API_SERVER_KEY}\n",
    +            encoding="utf-8",
    +        )
    +
    +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +        monkeypatch.setenv("WEBHOOK_SECRET", "whsec-expanded")
    +        monkeypatch.setenv("API_SERVER_KEY", "server-key-expanded")
    +        # A key NO env bridge reads: only the YAML-layer expansion can satisfy it, so this
    +        # assertion goes red when the loader hunk is reverted while the bridges stay.
    +        monkeypatch.setenv("HOOK_PREFIX_FOR_TEST", "/hooks/expanded")
    +
    +        config = load_gateway_config()
    +
    +        assert config.platforms[Platform.WEBHOOK].extra["secret"] == "whsec-expanded"
    +        assert config.platforms[Platform.WEBHOOK].extra["path_prefix"] == "/hooks/expanded"
    +        assert (
    +            config.platforms[Platform.API_SERVER].extra["key"] == "server-key-expanded"
    +        )
     
    +    def test_platforms_env_ref_unresolved_stays_literal(self, tmp_path, monkeypatch):
    +        """An unset env var keeps the literal placeholder (loader is fail-open; the adapter's
    +        startup validation is what reports a bad secret)."""
    +        hermes_home = tmp_path / ".hermes"
    +        hermes_home.mkdir()
    +        (hermes_home / "config.yaml").write_text(
    +            "platforms:\n"
    +            "  webhook:\n"
    +            "    enabled: true\n"
    +            "    secret: ${WEBHOOK_SECRET_UNSET_FOR_TEST}\n",
    +            encoding="utf-8",
    +        )
    +
    +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +        monkeypatch.delenv("WEBHOOK_SECRET_UNSET_FOR_TEST", raising=False)
    +
    +        config = load_gateway_config()
    +
    +        assert (
    +            config.platforms[Platform.WEBHOOK].extra["secret"]
    +            == "${WEBHOOK_SECRET_UNSET_FOR_TEST}"
    +        )
     
         def test_slack_ignored_channels_config_sets_env_bridge(self, tmp_path, monkeypatch):
             hermes_home = tmp_path / ".hermes"
    ```

    ```diff
    @@ -1407,6 +1462,50 @@ def test_env_key_does_not_reenable_explicitly_disabled_api_server(self):
     
     
     class TestWebhookEnvOverride:
    +    def test_config_enabled_webhook_reads_env_port_and_secret(self, tmp_path, monkeypatch):
    +        """A config.yaml-enabled webhook still receives its .env listener settings."""
    +        hermes_home = tmp_path / ".hermes"
    +        hermes_home.mkdir()
    +        (hermes_home / "config.yaml").write_text(
    +            "platforms:\n"
    +            "  webhook:\n"
    +            "    enabled: true\n",
    +            encoding="utf-8",
    +        )
    +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +        monkeypatch.delenv("WEBHOOK_ENABLED", raising=False)
    +        monkeypatch.setenv("WEBHOOK_PORT", "9012")
    +        monkeypatch.setenv("WEBHOOK_SECRET", "webhook-env-secret")
    +
    +        webhook = load_gateway_config().platforms[Platform.WEBHOOK]
    +
    +        assert webhook.enabled is True
    +        assert webhook.extra["port"] == 9012
    +        assert webhook.extra["secret"] == "webhook-env-secret"
    +
    +    def test_empty_env_secret_does_not_clobber_yaml_secret(self, tmp_path, monkeypatch):
    +        """``WEBHOOK_SECRET=`` (present but empty) must not erase a config.yaml secret: the
    +        widened bridge now runs for yaml-enabled webhooks, so an empty env value has to stay a
    +        no-op rather than turning a working HMAC key into an empty one."""
    +        hermes_home = tmp_path / ".hermes"
    +        hermes_home.mkdir()
    +        (hermes_home / "config.yaml").write_text(
    +            "platforms:\n"
    +            "  webhook:\n"
    +            "    enabled: true\n"
    +            "    secret: [redacted:secret-assignment]",
    +            encoding="utf-8",
    +        )
    +        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +        monkeypatch.delenv("WEBHOOK_ENABLED", raising=False)
    +        monkeypatch.delenv("WEBHOOK_PORT", raising=False)
    +        monkeypatch.setenv("WEBHOOK_SECRET", "")
    +
    +        webhook = load_gateway_config().platforms[Platform.WEBHOOK]
    +
    +        assert webhook.enabled is True
    +        assert webhook.extra["secret"] == "yaml-secret"
    +
         def test_env_key_does_not_reenable_explicitly_disabled_webhook(self):
             """An explicit ``platforms.webhook.enabled: false`` must survive
             _apply_env_overrides() even when WEBHOOK_ENABLED is truthy in the env.
    ```
  - `tests/gateway/test_planned_stop_watcher.py`

    ```diff
    @@ -201,3 +201,35 @@ def test_planned_stop_marker_targets_self_probe_is_non_destructive(tmp_path, mon
         assert status_mod.planned_stop_marker_targets_self() is True
     
     
    +def test_cli_sigterm_after_watcher_consumed_the_marker_stays_planned(tmp_path, monkeypatch):
    +    """`hermes gateway stop` writes the marker, THEN signals. When the watcher fires in between and
    +    consumes the marker, the trailing SIGTERM must not read as an external kill (exit 1 → a
    +    Restart=on-failure supervisor revives the gateway the operator stopped). A bare SIGTERM with no
    +    planned stop before it is still an external kill."""
    +    import signal
    +    from types import SimpleNamespace
    +
    +    import gateway.run as run_mod
    +    import gateway.shutdown_forensics as forensics
    +
    +    marker = tmp_path / ".gateway-planned-stop.json"
    +    monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
    +    monkeypatch.setattr(status_mod, "consume_takeover_marker_for_self", lambda: False)
    +    monkeypatch.setattr(forensics, "snapshot_shutdown_context", lambda *a, **k: None)
    +    monkeypatch.setattr(run_mod.asyncio, "create_task", lambda coro: coro.close())
    +
    +    def _handler():
    +        runner = SimpleNamespace(_signal_initiated_shutdown=False, stop=lambda: asyncio.sleep(0))
    +        flag = [False]
    +        return run_mod._start_gateway_make_shutdown_signal_handler(runner, flag), flag
    +
    +    handler, flag = _handler()
    +    _write_self_marker(marker)
    +    handler(None)  # the watcher's call: consumes the marker
    +    assert not marker.exists()
    +    handler(signal.SIGTERM)  # the CLI's own SIGTERM, marker already gone
    +    assert flag[0] is False
    +
    +    bare, bare_flag = _handler()
    +    bare(signal.SIGTERM)
    +    assert bare_flag[0] is True
    ```
  - `tests/gateway/test_session_model_override_persistence.py`

    ```diff
    @@ -182,6 +182,39 @@ def test_rehydrate_opencode_override_heals_relay_url_for_rederived_wire(store_fa
         assert (override["api_mode"], override["base_url"]) == ("chat_completions", "https://opencode.ai/zen/go/v1")
     
     
    [redacted:email]("codex_on_turn", ["recovers", "still_unavailable"])
    +def test_codex_override_never_runs_on_the_default_providers_endpoint(store_factory, codex_on_turn):
    +    """A persisted openai-codex override whose credentials fail to re-resolve used to be layered over the
    +    DEFAULT provider's runtime (Nous URL + Nous key + chat_completions). The turn runs on ONE coherent
    +    route: the override's own provider when it resolves, else the whole default route with a notice."""
    +    store = store_factory()
    +    session_key = store.get_or_create_session(_make_source()).session_key
    +    store.set_model_override(session_key, {"model": "gpt-6-luna-900k", "provider": "openai-codex",
    +                                           "base_url": "https://inference-api.nousresearch.com/v1"})
    +    runner = _make_runner(store_factory())
    +    codex = {"provider": "openai-codex", "api_key": "[redacted:secret-assignment]", "api_mode": "codex_responses",
    +             "base_url": "https://chatgpt.com/backend-api/codex"}
    +    nous = {"provider": "nous", "api_key": "[redacted:secret-assignment]", "api_mode": "chat_completions",
    +            "base_url": "https://inference-api.nousresearch.com/v1"}
    +    calls = iter([RuntimeError("refresh blip"), codex if codex_on_turn == "recovers" else RuntimeError("gone")])
    +
    +    def _for_provider(provider, target_model=None):
    +        assert provider == "openai-codex"
    +        nxt = next(calls)
    +        if isinstance(nxt, Exception):
    +            raise nxt
    +        return dict(nxt)
    +
    +    with patch("gateway.run._resolve_runtime_agent_kwargs_for_provider", side_effect=_for_provider), \
    +         patch("gateway.run._resolve_runtime_agent_kwargs", return_value=dict(nous)):
    +        model, runtime = runner._resolve_session_agent_runtime(
    +            session_key=session_key, user_config={"model": {"default": "openai/gpt-6-luna", "provider": "nous"}})
    +
    +    expected_model, expected = ("gpt-6-luna-900k", codex) if codex_on_turn == "recovers" else ("openai/gpt-6-luna", nous)
    +    assert (model, {k: runtime[k] for k in expected}) == (expected_model, expected)
    +    assert bool(runner._pre_agent_fallback_notice) is (codex_on_turn == "still_unavailable")
    +
    +
     def test_sanitize_model_override():
         assert sanitize_model_override(None) is None
         assert sanitize_model_override({}) is None
    ```
  - `tests/hermes_cli/test_fleet_matrix_self_restart_pending.py`

    ```diff
    @@ -0,0 +1,104 @@
    +"""A gateway this updater runs INSIDE (cron update in the gateway tree, #100179) accepted a
    +self-restart request and can only restart after the updater exits — so at fleet-matrix time it is
    +still on the pre-update code by construction. It used to render STALE and exit 1 on every nightly
    +cron update (#119597). Now the ancestor pid set recorded by the restart phase turns that row into
    +``restart_pending``; every OTHER gateway on the old sha keeps its ``stale`` verdict.
    +"""
    +
    +import contextlib
    +import io
    +import json
    +import os
    +
    +import pytest
    +
    +import hermes_cli.update_cmd_fleet as fleet_mod
    +import hermes_cli.update_receipt as ur
    +
    +OLD = "a" * 40
    +HEAD = "b" * 40
    +
    +
    +def _fleet_homes(monkeypatch, tmp_path, records: dict[str, dict]) -> None:
    +    """Default home + named profiles, each with a state file the identity resolver verifies as-is."""
    +    root = tmp_path / ".hermes"
    +    (root / "profiles").mkdir(parents=True)
    +    homes = {"default": root}
    +    for profile, record in records.items():
    +        home = root if profile == "default" else root / "profiles" / profile
    +        home.mkdir(exist_ok=True)
    +        homes[profile] = home
    +        (home / "gateway_state.json").write_text(json.dumps(record), encoding="utf-8")
    +    by_home = {str(home): rec["pid"] for profile, home in homes.items() for rec in [records[profile]]}
    +    monkeypatch.setattr("hermes_cli.build_info.get_code_identity", lambda refresh=False: {"sha": HEAD, "version": "1.0"})
    +    monkeypatch.setattr("hermes_cli.profiles._get_default_hermes_home", lambda: root)
    +    monkeypatch.setattr("hermes_cli.profiles._get_profiles_root", lambda: root / "profiles")
    +    monkeypatch.setattr("gateway.control_socket.identify_gateway", lambda h, **k: None)
    +    monkeypatch.setattr("gateway.status.live_gateway_pid_for_home", lambda h: by_home.get(str(h)))
    +    monkeypatch.setattr(ur, "_gateway_code_root", lambda pid, home: None)
    +
    +
    +def test_ancestor_with_accepted_self_restart_is_pending_while_other_stale_rows_still_fail(monkeypatch, tmp_path):
    +    ancestor, sibling = os.getpid(), os.getppid()
    +    _fleet_homes(monkeypatch, tmp_path, {
    +        "default": {"pid": ancestor, "gateway_state": "running", "code_sha": OLD},
    +        "ops": {"pid": sibling, "gateway_state": "running", "code_sha": OLD},
    +    })
    +
    +    fleet = ur.collect_fleet_versions(pre_restart_pids=[ancestor, sibling], self_restart_pending={ancestor})
    +    states = {row["profile"]: row["state"] for row in fleet}
    +    assert states == {"default": ur.RESTART_PENDING_STATE, "ops": "stale"}
    +    with contextlib.redirect_stdout(io.StringIO()) as out:
    +        assert ur.print_fleet_version_matrix(fleet) is True  # the sibling still fails the update
    +    assert "restart pending" in out.getvalue() and "1 gateway(s) still running the old code" in out.getvalue()
    +
    +    # Without the sibling, the pending row alone is not a failure — and the same pid NOT recorded
    +    # as pending (the restart phase never reached the ancestor branch) is a plain stale verdict.
    +    only_ancestor = [row for row in fleet if row["profile"] == "default"]
    +    with contextlib.redirect_stdout(io.StringIO()):
    +        assert ur.print_fleet_version_matrix(only_ancestor) is False
    +    plain = ur.collect_fleet_versions(pre_restart_pids=[ancestor, sibling])
    +    assert {row["state"] for row in plain} == {"stale"}
    +
    +
    +def test_restart_phase_records_accepted_self_restart_and_verify_exits_clean(monkeypatch, tmp_path):
    +    """The ancestor branch of the drain triage feeds the pid set the matrix reads: end to end the
    +    verify phase exits 0 (not 1) and clears the pending marker for an update whose only old-code
    +    gateway is the one it runs inside."""
    +    import hermes_cli.gateway as gateway
    +    import hermes_cli.update_cmd as update_cmd
    +
    +    ancestor = os.getpid()
    +    monkeypatch.setattr(gateway, "_is_pid_ancestor_of_current_process", lambda pid: pid == ancestor)
    +    monkeypatch.setattr(gateway, "_request_gateway_self_restart", lambda pid: True)
    +    pending: set = set()
    +    with contextlib.redirect_stdout(io.StringIO()):
    +        assert fleet_mod._drain_or_signal_gateway_for_update(ancestor, 5.0, "default", self_restart_pending=pending)
    +    assert pending == {ancestor}
    +
    +    _fleet_homes(monkeypatch, tmp_path, {"default": {"pid": ancestor, "gateway_state": "running", "code_sha": OLD}})
    +    monkeypatch.setattr(fleet_mod, "_print_legacy_units_warning", lambda: None)
    +    monkeypatch.setattr(update_cmd, "_finish_dashboard_update_cleanup", lambda *a, **k: None)
    +    monkeypatch.setattr(update_cmd, "_surviving_pre_update_serve_runtimes", lambda plan: [])
    +    monkeypatch.setattr(fleet_mod._time, "sleep", lambda s: None)
    +    cleared = []
    +    monkeypatch.setattr(fleet_mod, "_clear_fleet_restart_pending_marker", lambda: cleared.append(True))
    +    monkeypatch.setattr("hermes_cli.gateway_migrate.maybe_auto_migrate_after_update", lambda: None)
    +    restart = fleet_mod._GatewayRestartOutcome(
    +        incomplete=False, phase_errors=[], pre_restart_gateway_pids=[ancestor], restarted_services=["hermes-gateway"],
    +        failed_or_stale_units=[], relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(),
    +        self_restart_pending_pids=pending,
    +    )
    +    with contextlib.redirect_stdout(io.StringIO()) as out:
    +        fleet_mod._verify_fleet_after_update(
    +            restart, _pre_update_plan=None, _windows_gateway_resume=None, node_failures=[], update_complete=True,
    +        )  # a SystemExit(1) here is the #119597 symptom
    +    assert "restart pending" in out.getvalue()
    +    assert "Update not complete" not in out.getvalue()
    +    assert cleared == [True]
    +    with contextlib.redirect_stdout(io.StringIO()), pytest.raises(SystemExit) as exc:
    +        restart.self_restart_pending_pids = set()  # same fleet, identity not threaded → STALE, exit 1
    +        fleet_mod._verify_fleet_after_update(
    +            restart, _pre_update_plan=None, _windows_gateway_resume=None, node_failures=[], update_complete=True,
    +        )
    +    assert exc.value.code == 1
    ```
  - `tests/hermes_cli/test_gateway_linger.py`

    ```diff
    @@ -1,5 +1,6 @@
     """Tests for gateway linger auto-enable behavior on headless Linux installs."""
     
    +from pathlib import Path
     from types import SimpleNamespace
     
     import pytest
    ```

    ```diff
    @@ -7,12 +8,40 @@
     import hermes_cli.gateway as gateway
     
     
    +def _stub_linger_file(monkeypatch, tmp_path, *, exists: bool) -> None:
    +    """Point the ``/var/lib/systemd/linger/<user>`` probe at a real file under tmp_path.
    +
    +    ``gateway.Path`` stays ``pathlib.Path`` — every other ``Path(...)`` the code under test makes
    +    (``.expanduser()``, ``.resolve()`` in service-name resolution) keeps working. A ``SimpleNamespace``
    +    stand-in broke as soon as the code grew a call the stub did not implement (#119786).
    +    """
    +    linger_dir = Path("/var/lib/systemd/linger")
    +    fake_dir = tmp_path / "linger"
    +    fake_dir.mkdir()
    +    if exists:
    +        (fake_dir / "testuser").write_text("")
    +
    +    def redirected_path(path, *more):
    +        candidate = Path(path, *more)
    +        return fake_dir / candidate.name if candidate.parent == linger_dir else candidate
    +
    +    monkeypatch.setattr(gateway, "Path", redirected_path)
    +
    +
    +def _root_with_system_unit_pinned_home(monkeypatch, tmp_path) -> None:
    +    """Take the root/system-unit branch of service-name resolution (``_bare_unit_pinned_home``), the
    +    path that calls ``Path(...).expanduser()`` while rendering the restart hint."""
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
    +    monkeypatch.setattr(gateway.os, "geteuid", lambda: 0)
    +    monkeypatch.setattr(gateway, "_hermes_home_pinned_by_unit", lambda _unit_path: str(tmp_path / "system-home"))
    +
    +
     class TestEnsureLingerEnabled:
    -    def test_linger_already_enabled_via_file(self, monkeypatch, capsys):
    +    def test_linger_already_enabled_via_file(self, monkeypatch, capsys, tmp_path):
             monkeypatch.setattr(gateway, "is_linux", lambda: True)
             monkeypatch.setattr(gateway, "is_termux", lambda: False)
             monkeypatch.setattr("getpass.getuser", lambda: "testuser")
    -        monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: True))
    +        _stub_linger_file(monkeypatch, tmp_path, exists=True)
     
             calls = []
             monkeypatch.setattr(gateway.subprocess, "run", lambda *args, **kwargs: calls.append((args, kwargs)))
    ```

    ```diff
    @@ -22,11 +51,11 @@ def test_linger_already_enabled_via_file(self, monkeypatch, capsys):
             assert calls == []
     
     
    -    def test_loginctl_success_enables_linger(self, monkeypatch, capsys):
    +    def test_loginctl_success_enables_linger(self, monkeypatch, capsys, tmp_path):
             monkeypatch.setattr(gateway, "is_linux", lambda: True)
             monkeypatch.setattr(gateway, "is_termux", lambda: False)
             monkeypatch.setattr("getpass.getuser", lambda: "testuser")
    -        monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
    +        _stub_linger_file(monkeypatch, tmp_path, exists=False)
             monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda username=None: (False, ""))
             monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/loginctl")
     
    ```

    ```diff
    @@ -43,11 +72,12 @@ def fake_run(cmd, capture_output=False, text=False, check=False, **kwargs):
             assert run_calls == [(["loginctl", "enable-linger", "testuser"], True, True, False)]
     
     
    -    def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys):
    +    def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys, tmp_path):
             monkeypatch.setattr(gateway, "is_linux", lambda: True)
             monkeypatch.setattr(gateway, "is_termux", lambda: False)
             monkeypatch.setattr("getpass.getuser", lambda: "testuser")
    -        monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
    +        _stub_linger_file(monkeypatch, tmp_path, exists=False)
    +        _root_with_system_unit_pinned_home(monkeypatch, tmp_path)
             monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda username=None: (False, ""))
             monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/loginctl")
             monkeypatch.setattr(
    ```

    ```diff
    @@ -62,10 +92,10 @@ def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys):
             assert "sudo loginctl enable-linger testuser" in out
             assert "Permission denied" in out
     
    -    def test_system_scope_enables_target_user_without_logout_messaging(self, monkeypatch, capsys):
    +    def test_system_scope_enables_target_user_without_logout_messaging(self, monkeypatch, capsys, tmp_path):
             monkeypatch.setattr(gateway, "is_linux", lambda: True)
             monkeypatch.setattr(gateway, "is_termux", lambda: False)
    -        monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
    +        _stub_linger_file(monkeypatch, tmp_path, exists=False)
             monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda username=None: (False, ""))
             monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/loginctl")
             run_calls = []
    ```

    ```diff
    @@ -82,10 +112,11 @@ def fake_run(cmd, **kwargs):
             out = capsys.readouterr().out
             assert "alice" in out and "logout" not in out
     
    -    def test_system_scope_warning_uses_system_restart(self, monkeypatch, capsys):
    +    def test_system_scope_warning_uses_system_restart(self, monkeypatch, capsys, tmp_path):
             monkeypatch.setattr(gateway, "is_linux", lambda: True)
             monkeypatch.setattr(gateway, "is_termux", lambda: False)
    -        monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
    +        _stub_linger_file(monkeypatch, tmp_path, exists=False)
    +        _root_with_system_unit_pinned_home(monkeypatch, tmp_path)
             monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda username=None: (False, ""))
             monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/loginctl")
             monkeypatch.setattr(
    ```
  - `tests/hermes_cli/test_gateway_windows.py`

    ```diff
    @@ -50,7 +50,22 @@ def test_exec_schtasks_round_trips_non_ascii_task_argument_live(monkeypatch):
         `scheduled_task_drift` needs the exact characters back (#116193)."""
         monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", lambda *a, **k: "utf-8")
         task = f"Hermes_Test_{os.getpid()}"
    -    marker = "Zo\u00eb"  # ë: one byte in every Western OEM/ANSI code page, invalid as a lone UTF-8 byte
    +    # schtasks stores /TR in the system ANSI code page, so the marker must be representable THERE:
    +    # ë is one byte in every Western ACP but is destroyed ("?") on cp936/932/949 hosts, and a CJK
    +    # literal fails the other way on cp1252 (#119845). Derive it from the live ACP; skip only when
    +    # no non-ASCII candidate survives, so the #116193 guard keeps its coverage on every locale.
    +    import ctypes
    +    acp = f"cp{ctypes.windll.kernel32.GetACP()}"
    +
    +    def _encodable(text: str) -> bool:
    +        try:
    +            return text.encode(acp).decode(acp) == text
    +        except (UnicodeError, LookupError):
    +            return False
    +
    +    marker = next((c for c in ("Zo\u00eb", "\u65b9\u821f", "\u30c6\u30b9\u30c8", "\ud55c\uae00") if _encodable(c)), None)
    +    if marker is None:
    +        pytest.skip(f"no non-ASCII marker is representable in the host ANSI code page {acp}")
         created = subprocess.run(
             ["schtasks", "/Create", "/F", "/TN", task, "/SC", "ONLOGON", "/TR", f'wscript.exe //B "C:\\{marker}\\x.vbs"'],
             capture_output=True, timeout=30,
    ```
  - `tests/hermes_cli/test_pending_supervisor_recovery.py`

    ```diff
    @@ -7,6 +7,13 @@
     from hermes_cli import gateway, main, update_cmd_fleet as fleet, update_receipt
     
     
    [redacted:email](autouse=True)
    +def _units_belong_to_this_update(monkeypatch):
    +    """The fake ``hermes-gateway-one/two`` units carry no home; ownership (#93349,
    +    ``test_update_fleet_home_scope.py``) is pinned so this file keeps testing the catch-up contract."""
    +    monkeypatch.setattr(fleet, "_systemd_unit_owned_by_update", lambda scope_cmd, svc_name: True)
    +
    +
     @pytest.mark.linux_only
     @pytest.mark.parametrize("failure", ["listing", "timeout", "missing", "restart", "inactive", "running", "missing-owned", None])
     def test_pending_marker_requires_complete_systemd_recovery(monkeypatch, tmp_path, failure):
    ```
  - `tests/hermes_cli/test_process_dock.py`

    ```diff
    @@ -2,6 +2,7 @@
     import time
     from types import SimpleNamespace
     
    +from prompt_toolkit.utils import get_cwidth
     
     
     def _wait(predicate, timeout=5.0):
    ```

    ```diff
    @@ -11,6 +12,45 @@ def _wait(predicate, timeout=5.0):
             time.sleep(0.05)
     
     
    +def test_dock_paints_processes_under_agents_and_retires_finished_rows(monkeypatch):
    +    from hermes_cli import cli_process_dock
    +    from hermes_cli.cli_subagent_monitor import SubagentMonitor
    +    from tools import delegate_tool_registry as registry
    +    from tools.process_registry import process_registry
    +
    +    monkeypatch.setattr(registry, '_active_subagents', {})
    +    owner = SimpleNamespace(session_id='owner')
    +    registry._register_subagent(dict(subagent_id='a1', owner_agent_session_id='owner',
    +        goal='Check module', started_at=time.time() - 5, status='running', last_tool='read_file'))
    +    quick = process_registry.spawn_local(command="echo hello-dock; exit 3", cwd='.', task_id='t', owner_task_id='t', session_key='')
    +    slow = process_registry.spawn_local(command="sleep 30", cwd='.', task_id='t', owner_task_id='t', session_key='')
    +    quick_id, slow_id = quick.id, slow.id
    +    try:
    +        _wait(lambda: process_registry.get(quick_id).exited)
    +        process_registry.list_sessions()  # observes the exit → exited_at stamped
    +        dock = SubagentMonitor(SimpleNamespace(agent=owner))
    +        assert dock.refresh()
    +        text = dock.dock_text(columns=100, rows=30)
    +        lines = text.splitlines()
    +        assert 'Subagents · 1 live' in lines[0]
    +        agents_at = next(i for i, line in enumerate(lines) if 'Check module' in line)
    +        procs_at = next(i for i, line in enumerate(lines) if 'Processes · 1 running · 1 done' in line)
    +        assert agents_at < procs_at
    +        assert any('⚙ sleep 30' in line and 'starting' in line for line in lines)
    +        assert any('✘ echo hello-dock; exit 3 · exit 3' in line for line in lines)
    +        assert all(get_cwidth(line) <= 100 for line in lines)
    +        # Every viewport keeps at least one row of each block.
    +        narrow = dock.dock_text(columns=40, rows=14).splitlines()
    +        assert any('Check module' in line for line in narrow) and any('Processes' in line for line in narrow)
    +        assert all(get_cwidth(line) <= 40 for line in narrow)
    +        dock.collapsed = True
    +        assert dock.dock_text(columns=100, rows=30).count('\n') == 0
    +        assert '1 live · 1 proc' in dock.dock_text(columns=100, rows=30)
    +        # Finished rows leave after the retention window; running ones stay.
    +        later = cli_process_dock.process_rows(time.time() + cli_process_dock.RETAIN_SECONDS + 1)
    +        assert [r['id'] for r in later] == [slow_id]
    +    finally:
    +        process_registry.kill_process(slow_id)
     
     
     def test_monitor_controls_stop_processes_and_never_steer_them():
    ```
  - `tests/hermes_cli/test_profiles.py`

    ```diff
    @@ -9,6 +9,7 @@
     import os
     import shutil
     import socket
    +import stat
     import sys
     import tarfile
     import types
    ```

    ```diff
    @@ -217,6 +218,44 @@ def test_clone_config_copies_files(self, profile_env):
             assert (profile_dir / ".env").read_text().strip() == "KEY=val"
             assert (profile_dir / "SOUL.md").read_text() == "Be helpful."
     
    +    def test_clone_config_copies_only_the_active_memory_providers_config(self, profile_env):
    +        """#120115: --clone carried ``memory.provider: hindsight`` but not hindsight's own config,
    +        so the clone booted with memory silently unavailable. Only the ACTIVE provider's
    +        ``<provider>/`` dir / ``<provider>.json`` travels; another provider's leftovers stay behind."""
    +        tmp_path = profile_env
    +        default_home = tmp_path / ".hermes"
    +        (default_home / "config.yaml").write_text("memory:\n  provider: hindsight\n")
    +        (default_home / "hindsight").mkdir()
    +        payload = '{"mode": "local_embedded", "bank_id": "hermes", "apiKey": "[redacted:secret-assignment]"}'
    +        (default_home / "hindsight" / "config.json").write_text(payload)
    +        (default_home / "mem0.json").write_text('{"agent_id": "hermes"}')
    +
    +        profile_dir = create_profile("coder", clone_config=True, no_alias=True)
    +
    +        cloned = profile_dir / "hindsight" / "config.json"
    +        assert cloned.read_text() == payload
    +        if os.name != "nt":
    +            assert stat.S_IMODE(cloned.stat().st_mode) == 0o600
    +        assert not (profile_dir / "mem0.json").exists()
    +
    +    @pytest.mark.parametrize("provider", ["../outside", "a/b", "..", "hind sight"])
    +    def test_clone_config_ignores_unsafe_memory_provider_names(self, profile_env, provider):
    +        """A hand-edited ``memory.provider`` must never aim the copy outside the source profile."""
    +        tmp_path = profile_env
    +        default_home = tmp_path / ".hermes"
    +        (default_home / "config.yaml").write_text(f"memory:\n  provider: {provider!r}\n")
    +        (tmp_path / "outside").mkdir()
    +        (tmp_path / "outside" / "config.json").write_text("{}")
    +        (default_home / "a").mkdir()
    +        (default_home / "a" / "b").mkdir()
    +        (default_home / "a" / "b" / "config.json").write_text("{}")
    +
    +        profile_dir = create_profile("coder", clone_config=True, no_alias=True)
    +
    +        assert not (profile_dir / "a").exists()
    +        assert not (profile_dir.parent / "outside").exists()
    +        assert not (profile_dir / "hind sight").exists()
    +
         def test_clone_sync_imports_carries_manifest_but_never_links_profiles(self, profile_env):
             """--sync-imports copies import-sync.json (a pointer at EXTERNAL agent trees) and nothing
             else changes: the clone still gets its own config/skills copies, never a live link."""
    ```
  - `tests/hermes_cli/test_update_fleet_home_scope.py`

    ```diff
    @@ -0,0 +1,95 @@
    +"""#93349 — ``hermes update`` restarts only the gateways of the home it is updating.
    +
    +``hermes-gateway*`` units and ``gateway run`` processes are host-wide namespaces shared by every
    +Hermes install under the account. A scratch home's update used to drain and restart the account's
    +real ``hermes-gateway.service`` and SIGTERM sibling installs' gateways because they were listed,
    +not because they ran the updated code.
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +import signal
    +import subprocess
    +from pathlib import Path
    +
    +import pytest
    +
    +from hermes_cli import update_cmd_fleet as fleet
    +from hermes_cli import dashboard_procs
    +
    +FOREIGN_HOME = "/srv/other-account-home/.hermes"
    +
    +
    [redacted:email]
    +def own_home(monkeypatch, tmp_path):
    +    home = tmp_path / "homeA" / ".hermes"
    +    home.mkdir(parents=True)
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    # The plan inventory is this install only; the fixture home has no profiles dir.
    +    monkeypatch.setattr("hermes_cli.update_receipt._profile_homes", lambda: [("default", home)])
    +    return home
    +
    +
    +def _pid_homes(monkeypatch, mapping: dict):
    +    monkeypatch.setattr(dashboard_procs, "_hermes_home_for_pid", lambda pid: mapping.get(pid))
    +
    +
    +def test_systemd_unit_of_another_home_is_left_alone(monkeypatch, own_home):
    +    """user/hermes-gateway runs on another HERMES_HOME → no drain, no restart, not a failure;
    +    the same unit running on the updating home is still restarted (control)."""
    +    homes = {4242: FOREIGN_HOME, 4343: str(own_home)}
    +    _pid_homes(monkeypatch, homes)
    +    main_pid = {"value": 4242}
    +    write_verbs: list[list[str]] = []
    +
    +    def fake_systemctl(cmd, *, timeout):
    +        if "is-active" in cmd:
    +            return subprocess.CompletedProcess(cmd, 0, stdout="active\n", stderr="")
    +        if "show" in cmd and "--property=MainPID" in cmd:
    +            return subprocess.CompletedProcess(cmd, 0, stdout=f"{main_pid['value']}\n", stderr="")
    +        if "show" in cmd:
    +            return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
    +        write_verbs.append(cmd)
    +        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
    +
    +    monkeypatch.setattr(fleet, "_systemctl", fake_systemctl)
    +    monkeypatch.setattr(fleet, "_repair_unit_without_fatal_exit_park", lambda *a, **k: None)
    +    drained: list[int] = []
    +    monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda pid, *a, **k: drained.append(pid) or True)
    +    monkeypatch.setattr(fleet, "_wait_for_service_active", lambda *a, **k: True)
    +
    +    restarted: list[str] = []
    +    failed: list[str] = []
    +    fleet._restart_one_systemd_gateway_unit(
    +        "hermes-gateway", scope="user", scope_cmd=["systemctl", "--user"], drain_budget=5.0,
    +        _manage_cmd_cache={}, restarted_services=restarted, failed_or_stale_units=failed,
    +    )
    +    assert drained == [] and write_verbs == [] and restarted == [] and failed == []
    +
    +    main_pid["value"] = 4343  # control: same unit name, this update's home
    +    fleet._restart_one_systemd_gateway_unit(
    +        "hermes-gateway", scope="user", scope_cmd=["systemctl", "--user"], drain_budget=5.0,
    +        _manage_cmd_cache={}, restarted_services=restarted, failed_or_stale_units=failed,
    +    )
    +    assert drained == [4343] and restarted == ["hermes-gateway"] and failed == []
    +
    +
    +def test_manual_gateway_of_another_home_is_not_stopped(monkeypatch, own_home):
    +    """Of two ``gateway run`` processes on the host, only the one on the updating home is SIGTERMed;
    +    a process whose home cannot be read is spared as well."""
    +    _pid_homes(monkeypatch, {111: str(own_home), 222: FOREIGN_HOME, 333: None})
    +    monkeypatch.setattr("hermes_cli.gateway._get_service_pids", lambda **k: set())
    +    monkeypatch.setattr("hermes_cli.gateway.find_gateway_pids", lambda **k: [111, 222, 333])
    +    monkeypatch.setattr("hermes_cli.gateway.find_profile_gateway_processes", lambda **k: [])
    +    monkeypatch.setattr("hermes_cli.gateway._wait_for_gateway_exit", lambda **k: None)
    +    killed: list[tuple[int, int]] = []
    +    monkeypatch.setattr(os, "kill", lambda pid, sig: killed.append((pid, sig)))
    +
    +    out = fleet._GatewayRestartOutcome(
    +        incomplete=False, phase_errors=[], pre_restart_gateway_pids=[], restarted_services=[],
    +        failed_or_stale_units=[], relaunched_profiles=[], externally_supervised_profiles=[], killed_pids=set(),
    +    )
    +    fleet._restart_manual_gateways(out, 5.0)
    +    assert killed == [(111, signal.SIGTERM)]
    +    assert out.killed_pids == {111}
    ```
  - `tests/hermes_cli/test_update_handoff_checkout_cwd.py`

    ```diff
    @@ -0,0 +1,160 @@
    +"""The post-swap child must start in the checkout, not the caller's cwd.
    +
    +A stale setuptools finder cannot import a new top-level package. ``python -m``
    +only adds the process cwd to ``sys.path``, so a hand-off spawned from outside
    +the checkout dies before the dependency sync can refresh the map.
    +"""
    +
    +import os
    +import subprocess
    +import sys
    +import textwrap
    +import time
    +
    +import hermes_cli.update_handoff as handoff
    +
    +
    +FINDER_SRC = textwrap.dedent(
    +    """\
    +    import sys
    +    from importlib.machinery import PathFinder
    +    from pathlib import Path
    +
    +    MAPPING = {"oldpkg": %r}
    +
    +    class _Finder:
    +        @classmethod
    +        def find_spec(cls, fullname, path, target=None):
    +            root = MAPPING.get(fullname.split(".", 1)[0])
    +            if root is None:
    +                return None
    +            return PathFinder.find_spec(fullname, [str(Path(root).parent)])
    +
    +    def install():
    +        if _Finder not in sys.meta_path:
    +            sys.meta_path.insert(0, _Finder)
    +    """
    +)
    +
    +CHILD_SRC = textwrap.dedent(
    +    """\
    +    import importlib.util
    +    import os
    +    import pathlib
    +
    +    spec = importlib.util.spec_from_file_location(
    +        "stale_finder", os.environ["HANDOFF_FINDER"])
    +    mod = importlib.util.module_from_spec(spec)
    +    spec.loader.exec_module(mod)
    +    mod.install()
    +    import oldpkg
    +    pathlib.Path(os.environ["HANDOFF_MARKER"]).write_text("sync:" + oldpkg.MARKER)
    +    """
    +)
    +
    +
    +class _Child:
    +    def wait(self, timeout=None):
    +        return 0
    +
    +
    +def _layout(tmp_path):
    +    checkout = tmp_path / "checkout"
    +    outside = tmp_path / "outside"
    +    outside.mkdir()
    +    oldpkg = checkout / "oldpkg"
    +    newpkg = checkout / "handoff_newpkg"
    +    oldpkg.mkdir(parents=True)
    +    newpkg.mkdir()
    +    (oldpkg / "__init__.py").write_text(
    +        "import handoff_newpkg\nMARKER = handoff_newpkg.MARKER\n",
    +        encoding="utf-8",
    +    )
    +    (newpkg / "__init__.py").write_text("MARKER = 'sync'\n", encoding="utf-8")
    +    finder = tmp_path / "stale_finder.py"
    +    finder.write_text(FINDER_SRC % str(oldpkg), encoding="utf-8")
    +    return checkout, outside, finder
    +
    +
    +def _child_env(finder, marker):
    +    env = os.environ.copy()
    +    env["HANDOFF_FINDER"] = str(finder)
    +    env["HANDOFF_MARKER"] = str(marker)
    +    env["PYTHONPATH"] = ""
    +    env.pop("PYTHONSAFEPATH", None)
    +    return env
    +
    +
    +def _run(cmd_cwd, env):
    +    return subprocess.run(
    +        [sys.executable, "-c", CHILD_SRC],
    +        cwd=cmd_cwd,
    +        env=env,
    +        capture_output=True,
    +        text=True,
    +        check=False,
    +    )
    +
    +
    +def test_both_handoff_launches_use_the_checkout_cwd(monkeypatch, tmp_path):
    +    recorded = []
    +    checkout = tmp_path / "checkout"
    +    checkout.mkdir()
    +    assert handoff._post_swap_cwd() == str(handoff.Path(handoff.__file__).resolve().parent.parent)
    +
    +    monkeypatch.setattr(handoff, "write_handoff", lambda payload: tmp_path / "handoff.json")
    +    monkeypatch.setattr(handoff, "post_swap_python", lambda: sys.executable)
    +    monkeypatch.setattr(handoff, "post_swap_child_env", lambda: {"HERMES_UPDATE_POST_SWAP": "1"})
    +    monkeypatch.setattr(handoff, "detached_shim_child_env", lambda env: env)
    +    monkeypatch.setattr(handoff, "_post_swap_cwd", lambda: str(checkout))
    +    monkeypatch.setattr(
    +        handoff.subprocess,
    +        "Popen",
    +        lambda cmd, **kwargs: recorded.append(kwargs.get("cwd")) or _Child(),
    +    )
    +
    +    monkeypatch.setattr(handoff, "_running_from_windows_shim", lambda: False)
    +    assert handoff.continue_update_in_fresh_interpreter({}, argv_tail=["--yes"]) == 0
    +
    +    monkeypatch.setattr(handoff, "_running_from_windows_shim", lambda: True)
    +    assert handoff.continue_update_in_fresh_interpreter({}, argv_tail=["--yes"]) == 0
    +
    +    assert recorded == [str(checkout), str(checkout)]
    +
    +
    +def test_outside_cwd_child_reaches_the_new_package(monkeypatch, tmp_path):
    +    """Parent cwd is outside; the stale map omits the new package; the child still imports it."""
    +    checkout, outside, finder = _layout(tmp_path)
    +    marker = tmp_path / "sync.txt"
    +    env = _child_env(finder, marker)
    +    monkeypatch.chdir(outside)
    +
    +    direct = _run(outside, env)
    +    assert direct.returncode != 0
    +    assert "handoff_newpkg" in (direct.stderr or "")
    +    assert not marker.exists()
    +
    +    inside = _run(checkout, env)
    +    assert inside.returncode == 0
    +    assert marker.read_text(encoding="utf-8") == "sync:sync"
    +    marker.unlink()
    +
    +    monkeypatch.setattr(handoff, "write_handoff", lambda payload: tmp_path / "handoff.json")
    +    monkeypatch.setattr(handoff, "post_swap_command", lambda path, tail: [sys.executable, "-c", CHILD_SRC])
    +    monkeypatch.setattr(handoff, "post_swap_child_env", lambda: env)
    +    monkeypatch.setattr(handoff, "detached_shim_child_env", lambda child_env: child_env)
    +    monkeypatch.setattr(handoff, "_post_swap_cwd", lambda: str(checkout))
    +    monkeypatch.setattr(handoff, "_running_from_windows_shim", lambda: False)
    +    monkeypatch.setattr(sys, "stdin", open(os.devnull, "r"))
    +
    +    assert handoff.continue_update_in_fresh_interpreter({}, argv_tail=[]) == 0
    +    assert marker.read_text(encoding="utf-8") == "sync:sync"
    +    marker.unlink()
    +
    +    monkeypatch.setattr(handoff, "_running_from_windows_shim", lambda: True)
    +    assert handoff.continue_update_in_fresh_interpreter({}, argv_tail=[]) == 0
    +    for _ in range(50):
    +        if marker.exists():
    +            break
    +        time.sleep(0.1)
    +    assert marker.read_text(encoding="utf-8") == "sync:sync"
    ```
  - `tests/hermes_cli/test_update_host_obligation.py`

    ```diff
    @@ -28,6 +28,14 @@
     SHA = "a" * 40
     
     
    [redacted:email](autouse=True)
    +def _units_belong_to_this_update(monkeypatch):
    +    """The fake units here run on invented PIDs (4242, per-unit tables) with no readable home;
    +    ownership (#93349, ``test_update_fleet_home_scope.py``) is pinned so these tests keep proving
    +    the once-per-host-process collapse, not home scoping."""
    +    monkeypatch.setattr(fleet, "_systemd_unit_owned_by_update", lambda scope_cmd, svc_name: True)
    +
    +
     @pytest.fixture
     def two_profiles(tmp_path, monkeypatch):
         """Two profile HERMES_HOMEs behind ONE host state dir — the real multiplex topology."""
    ```
  - `tests/hermes_cli/test_update_import_guard.py`

    ```diff
    @@ -399,3 +399,30 @@ def test_hint_fires_for_each_first_party_root(modname):
         assert partial_update_hint(exc), f"expected guidance for {modname}"
     
     
    +def test_import_probe_sees_a_stale_editable_finder_instead_of_the_checkout_cwd(monkeypatch, tmp_path):
    +    """``-c`` puts the checkout cwd on sys.path[0], so a venv whose editable finder cannot resolve a
    +    new top-level package still probed green — the exact state that crash-loops a gateway started
    +    from ``/`` (#119466). With an editable install present the probe runs under ``-P`` and reports
    +    the first-party ModuleNotFoundError; a venv WITHOUT an editable install keeps the cwd path."""
    +    (tmp_path / "hermes_probe_pkg").mkdir()
    +    (tmp_path / "hermes_probe_pkg" / "__init__.py").write_text("")
    +    venv = tmp_path / "venv"
    +    python = venv / "bin" / "python"
    +    python.parent.mkdir(parents=True)
    +    python.symlink_to(sys.executable)
    +    monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("hermes_probe_pkg",))
    +    monkeypatch.setattr(update_cmd_deps, "_UPDATE_CRITICAL_MODULES", ("hermes_probe_pkg",))
    +    monkeypatch.setattr(update_cmd_deps, "project_venv_dir", lambda root: venv)
    +    monkeypatch.setattr(update_cmd_deps, "_editable_finder_files", lambda v: [])
    +
    +    assert update_cmd_deps._critical_module_import_failures(tmp_path) == {}  # dev checkout: cwd vouches
    +
    +    site = venv / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages"
    +    site.mkdir(parents=True)
    +    finder = site / "__editable___hermes_agent_0_21_0_finder.py"
    +    finder.write_text("MAPPING = {}\n", encoding="utf-8")  # stale: no hermes_probe_pkg
    +    monkeypatch.setattr(update_cmd_deps, "_editable_finder_files", lambda v: [finder])
    +
    +    failures = update_cmd_deps._critical_module_import_failures(tmp_path)
    +    assert set(failures) == {"hermes_probe_pkg"}
    +    assert failures["hermes_probe_pkg"][0] == "ModuleNotFoundError"
    ```
  - `tests/hermes_cli/test_update_skip_unchanged_editable_install.py`

    ```diff
    @@ -13,6 +13,7 @@
     """
     
     import subprocess
    +from pathlib import Path
     
     import pytest
     
    ```

    ```diff
    @@ -49,6 +50,54 @@ def _commit(cwd, message):
         subprocess.run(GIT + ["commit", "-qm", message], cwd=cwd, check=True)
     
     
    +def _finder(repo, mapping, *, layout="lib/python3.11/site-packages", annotated=False):
    +    site = repo / "venv" / Path(layout)
    +    site.mkdir(parents=True)
    +    body = (
    +        "MAPPING: dict[str, str] = " + repr(mapping) + "\n"
    +        if annotated
    +        else "MAPPING = " + repr(mapping) + "\n"
    +    )
    +    (site / "__editable___hermes_agent_0_21_4_finder.py").write_text(body, encoding="utf-8")
    +    return site
    +
    +
    +def _declare_packages(repo, include):
    +    (repo / "pyproject.toml").write_text(
    +        "[tool.setuptools.packages.find]\ninclude = " + repr(include) + "\n",
    +        encoding="utf-8",
    +    )
    +    _commit(repo, "declare packages")
    +
    +
    [redacted:email]("annotated", [False, True])
    +def test_stale_or_unreadable_finder_map_refuses_the_skip(repo, monkeypatch, annotated):
    +    """A map missing a top-level name (either finder spelling) or an unparseable finder refuses
    +    the skip even though the pull touched no packaging file."""
    +    _declare_packages(repo, ["agent", "agent.*", "newpkg", "newpkg.*"])
    +    before = _head(repo)
    +    (repo / "newpkg").mkdir()
    +    (repo / "newpkg" / "__init__.py").write_text("")
    +    _commit(repo, "add package")
    +    site = _finder(repo, {"agent": str(repo / "agent"), "cli": str(repo / "cli")}, annotated=annotated)
    +    monkeypatch.setattr("hermes_cli.update_cmd_deps.project_venv_dir", lambda _: repo / "venv")
    +    assert _editable_install_is_current(GIT, repo, before) is False
    +    (site / "__editable___hermes_agent_0_21_4_finder.py").write_text("MAPPING = (\n", encoding="utf-8")
    +    assert _editable_install_is_current(GIT, repo, before) is False
    +
    +
    [redacted:email]("layout", ["lib/python3.11/site-packages", "Lib/site-packages"])
    +def test_covered_map_skips_source_churn(repo, monkeypatch, layout):
    +    """A map that already names the checkout still skips source edits (POSIX and Windows venvs)."""
    +    _declare_packages(repo, ["agent", "agent.*"])
    +    _finder(repo, {"agent": str(repo / "agent"), "cli": str(repo / "cli")}, layout=layout)
    +    monkeypatch.setattr("hermes_cli.update_cmd_deps.project_venv_dir", lambda _: repo / "venv")
    +    before = _head(repo)
    +    (repo / "agent" / "loop.py").write_text("y = 2\n")
    +    _commit(repo, "source churn")
    +    assert _editable_install_is_current(GIT, repo, before) is True
    +
    +
     def test_source_only_pull_skips_the_reinstall(repo):
         """The common update: .py churn inside already-mapped packages."""
         before = _head(repo)
    ```
  - `tests/hermes_cli/test_update_unit_client_budget.py`

    ```diff
    @@ -6,6 +6,13 @@
     from hermes_cli import update_cmd_fleet as fleet
     
     
    [redacted:email](autouse=True)
    +def _units_belong_to_this_update(monkeypatch):
    +    """Fake units on an invented MainPID (42) have no readable home; ownership (#93349,
    +    ``test_update_fleet_home_scope.py``) is pinned so these tests keep proving budgets and health."""
    +    monkeypatch.setattr(fleet, "_systemd_unit_owned_by_update", lambda scope_cmd, svc_name: True)
    +
    +
     @pytest.mark.parametrize("graceful,retry", [(False, False), (False, True), (True, False), ("catchup", False)])
     def test_unit_transaction_budget_preserves_scope_and_health(monkeypatch, graceful, retry):
         catchup = graceful == "catchup"
    ```

    ```diff
    @@ -34,7 +41,7 @@ def systemctl(cmd, *, timeout):
             assert not failed
             assert sum("restart" in cmd for cmd, _ in calls) == 1
             return
    -    monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda *a: True)
    +    monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda *a, **kw: True)
         health = iter([False, True] if retry else [True])
         monkeypatch.setattr(fleet, "_wait_for_service_active", lambda *a, **kw: next(health))
         name = "hermes-gateway-test" if graceful else "hermes-serve-test"
    ```

    ```diff
    @@ -90,7 +97,7 @@ def test_fleet_restart_repairs_a_system_unit_that_cannot_park_on_exit_78(monkeyp
         refreshed = []
         monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: refreshed.append(system))
         monkeypatch.setattr(fleet, "_systemctl", lambda cmd, *, timeout: subprocess.CompletedProcess(cmd, 0, "active", ""))
    -    monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda *a: True)
    +    monkeypatch.setattr(fleet, "_drain_or_signal_gateway_for_update", lambda *a, **kw: True)
         monkeypatch.setattr(fleet, "_wait_for_service_active", lambda *a, **kw: True)
     
         for name in ("hermes-gateway", "hermes-gateway-ops"):
    ```
  - `tests/hermes_cli/test_web_server_sessiondb_eventloop.py`

    ```diff
    @@ -0,0 +1,76 @@
    +import ast
    +import asyncio
    +import threading
    +from pathlib import Path
    +
    +import pytest
    +
    +from hermes_cli import web_server
    +import hermes_cli.web_models as _web_models
    +import hermes_cli.web_routers.sessions as _rt_sessions
    +import hermes_cli.web_server_sessions as _web_server_sessions
    +from hermes_cli import web_server_sessions
    +from hermes_cli.web_routers import analytics as web_analytics
    +from hermes_cli.web_routers import sessions as web_sessions
    +
    +
    +TARGET_HANDLERS = {
    +    "bulk_delete_sessions_endpoint",
    +    "count_empty_sessions_endpoint",
    +    "delete_empty_sessions_endpoint",
    +    "get_session_latest_descendant",
    +    "get_session_messages",
    +    "delete_session_endpoint",
    +    "export_session_endpoint",
    +    "prune_sessions_endpoint",
    +    "get_usage_analytics",
    +    "get_models_analytics",
    +    "search_sessions",
    +    "get_session_stats",
    +    "get_session_detail",
    +}
    +
    +
    +def _call_name(call: ast.Call) -> str | None:
    +    if isinstance(call.func, ast.Name):
    +        return call.func.id
    +    if isinstance(call.func, ast.Attribute):
    +        return call.func.attr
    +    return None
    +
    +
    +def test_session_rename_runs_writer_open_and_update_off_event_loop(monkeypatch):
    +    loop_thread = threading.get_ident()
    +    db_threads: list[int] = []
    +
    +    class _DB:
    +        def set_session_title(self, sid, title):
    +            db_threads.append(threading.get_ident())
    +            assert (sid, title) == ("sess-1", "renamed")
    +
    +        def get_session_title(self, sid):
    +            db_threads.append(threading.get_ident())
    +            assert sid == "sess-1"
    +            return "renamed"
    +
    +        def close(self):
    +            db_threads.append(threading.get_ident())
    +
    +    def _open_db(profile=None, *, read_only):
    +        db_threads.append(threading.get_ident())
    +        assert profile is None
    +        assert read_only is False
    +        return _DB()
    +
    +    monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
    +    monkeypatch.setattr(_rt_sessions, "_resolve_session_id", lambda db, sid: sid)
    +
    +    result = asyncio.run(
    +        _rt_sessions.rename_session_endpoint(
    +            "sess-1", _web_models.SessionRename(title="renamed")
    +        )
    +    )
    +
    +    assert result == {"ok": True, "title": "renamed"}
    +    assert db_threads
    +    assert all(thread_id != loop_thread for thread_id in db_threads)
    ```
  - `tests/plugins/memory/test_holographic_profile_db_path.py`

    ```diff
    @@ -0,0 +1,93 @@
    +"""The holographic fact DB set up with the default path belongs to whichever profile opens it.
    +
    +Setup used to offer, and store, the active profile's concrete path (``~/.hermes/memory_store.db``).
    +``profile create --clone`` copies config.yaml verbatim, so the clone opened the source profile's
    +facts; ``profile rename`` moves the directory, so the renamed profile opened an empty DB in a
    +re-created directory under its old name.
    +"""
    +from __future__ import annotations
    +
    +import io
    +import sys
    +from pathlib import Path
    +from unittest.mock import patch
    +
    +import pytest
    +import yaml
    +
    +import hermes_cli.memory_setup as memory_setup
    +from hermes_cli.profiles import create_profile, rename_profile
    +from plugins.memory.holographic import HolographicMemoryProvider, _load_plugin_config
    +
    +
    [redacted:email]()
    +def root(tmp_path, monkeypatch):
    +    # A ``~/`` path is displayed through Path.home() and expanded through HOME; both name the sandbox.
    +    monkeypatch.setenv("HOME", str(tmp_path))
    +    monkeypatch.setattr(Path, "home", lambda: tmp_path)
    +    home = tmp_path / ".hermes"
    +    home.mkdir()
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    return home
    +
    +
    +def _setup_with_defaults(monkeypatch, home: Path) -> dict:
    +    """``hermes memory setup`` for holographic with Enter on every field, saved as ``cmd_setup`` does."""
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    provider = HolographicMemoryProvider(config={"hrr_dim": 64})
    +    monkeypatch.setattr(memory_setup, "_curses_select", lambda *_a, default=0, **_k: default)
    +    monkeypatch.setattr(sys, "stdin", io.StringIO("\n" * 10))
    +    answers: dict = {}
    +    assert memory_setup._prompt_schema_fields("holographic", provider.get_config_schema(), answers, {})
    +    provider.save_config(answers, str(home))
    +    return answers
    +
    +
    +def _opened_db(monkeypatch, home: Path) -> Path:
    +    """The DB a session started with ``hermes -p <profile>`` opens."""
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    provider = HolographicMemoryProvider(config=_load_plugin_config())
    +    provider.initialize("s")
    +    try:
    +        return provider._store.db_path.resolve()
    +    finally:
    +        provider.shutdown()
    +
    +
    [redacted:email]("lifecycle", ["clone", "rename"])
    +def test_default_db_path_follows_the_profile_that_opens_it(root, monkeypatch, lifecycle):
    +    if lifecycle == "clone":
    +        answers = _setup_with_defaults(monkeypatch, root)
    +        monkeypatch.setenv("HERMES_HOME", str(root))
    +        profile = create_profile("work", clone_config=True, no_alias=True)
    +    else:
    +        source = create_profile("alpha", no_alias=True)
    +        answers = _setup_with_defaults(monkeypatch, source)
    +        monkeypatch.setenv("HERMES_HOME", str(root))
    +        with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
    +            profile = rename_profile("alpha", "beta")
    +
    +    assert _opened_db(monkeypatch, profile) == (profile / "memory_store.db").resolve()
    +    if lifecycle == "rename":
    +        assert not (root / "profiles" / "alpha").exists()
    +    # The prompt offers the value it stores, so what the user accepted is what config.yaml says.
    +    assert answers["db_path"] == "$HERMES_HOME/memory_store.db"
    +
    +
    [redacted:email]("spelling", ["display", "absolute", "elsewhere"])
    +def test_save_config_stores_only_this_profiles_default_path_as_the_placeholder(root, monkeypatch, spelling):
    +    # "display"/"absolute": the concrete value an older setup wrote, and the dashboard form re-submits on
    +    # its next save. "elsewhere": a deliberately shared store (#4726) is kept as given.
    +    profile = create_profile("work", no_alias=True)
    +    monkeypatch.setenv("HERMES_HOME", str(profile))
    +    given = {
    +        "display": "~/.hermes/profiles/work/memory_store.db",
    +        "absolute": str(profile / "memory_store.db"),
    +        "elsewhere": str(root / "shared" / "facts.db"),
    +    }[spelling]
    +    expected = given if spelling == "elsewhere" else "$HERMES_HOME/memory_store.db"
    +
    +    HolographicMemoryProvider(config={"hrr_dim": 64}).save_config({"db_path": given, "hrr_dim": "64"}, str(profile))
    +
    +    stored = yaml.safe_load((profile / "config.yaml").read_text(encoding="utf-8"))["plugins"]["hermes-memory-store"]
    +    assert stored == {"db_path": expected, "hrr_dim": "64"}
    ```
  - `tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py`

    ```diff
    @@ -0,0 +1,73 @@
    +"""The Windows hand-off's `--gateway` flag, source-level (Linux CI cannot run PowerShell).
    +
    +`hermes update --gateway` (re)starts the local messaging gateway after the
    +update. A Desktop served by a remote gateway (#117529) must not ask for that:
    +the restarted local gateway shares the remote host's channel credentials and
    +becomes a competing long-poll consumer — Telegram answers the conflict by
    +rejecting one of the two `getUpdates` callers, taking the production bot
    +offline. The Desktop passes `-NoGateway` on that path; these tests pin the
    +script side of the contract the same source-level way
    +`test_desktop_update_windows_python_handoff.py` guards its invocation rule.
    +"""
    +
    +from __future__ import annotations
    +
    +import re
    +from pathlib import Path
    +
    +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
    +WINDOWS_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1"
    +
    +
    +def _handoff_source() -> str:
    +    """The script with its ``-SelfTest*`` fixture blocks removed (same strip as
    +    test_desktop_update_windows_python_handoff.py), normalized to LF."""
    +    source = WINDOWS_PS1.read_text(encoding="utf-8").replace("\r\n", "\n")
    +    return re.sub(
    +        r"\n(?P<indent> *)if \(\$SelfTest\w+\) \{.*?\n(?P=indent)\}\n",
    +        "\n",
    +        source,
    +    )
    +
    +
    +def test_successful_local_update_restarts_all_gateways_after_verification() -> None:
    +    """Desktop stops every profile before hand-off, outside update's inventory.
    +
    +    A successful local hand-off must consequently restore the same all-profile
    +    set itself. The restart belongs after runtime verification, and a
    +    remote-served Desktop must retain its ``-NoGateway`` opt-out.
    +    """
    +    source = _handoff_source()
    +    verify = 'Invoke-HermesStep $pythonExe @("-c", $verifyCode) "verify"'
    +    restart = (
    +        'Invoke-HermesStep $pythonExe @("-m", "hermes_cli.main", '
    +        '"gateway", "start", "--all") "gateway restart"'
    +    )
    +
    +    assert verify in source
    +    assert restart in source, (
    +        "a verified successful Desktop update must restore every gateway "
    +        "that Desktop stopped before the hand-off"
    +    )
    +    assert source.index(verify) < source.index(restart), (
    +        "gateway restoration must not run before the update runtime verifies"
    +    )
    +
    +    restart_block = source[source.index(restart) - 240:source.index(restart) + len(restart)]
    +    assert "-not $NoGateway" in restart_block, (
    +        "-NoGateway must keep remote-served Desktop from starting a local "
    +        "messaging gateway"
    +    )
    +
    +    # The update has already succeeded when the restart runs: a restart
    +    # failure surfaces as a manual follow-up (Write-Result's manual flag, the
    +    # Desktop's boot dialog), never as a non-zero exit that reads as a failed
    +    # update and triggers the error finale.
    +    after_restart = source[source.index(restart):]
    +    # The failure branch is the `if ($gatewayRestart.Code -ne 0) { ... }` block right
    +    # after the step; the normal success finale that follows it is out of scope.
    +    failure_branch = after_restart[: after_restart.index("if ($res.Code -eq 0 -and -not $desktopBuildFailed) {")]
    +    assert "$manualAction = $true" in failure_branch
    +    assert "$finalCode =" not in failure_branch, (
    +        "a gateway restart failure must not rewrite the update's exit code"
    +    )
    ```
  - `tests/tools/test_kill_verify_tree_death.py`

    ```diff
    @@ -124,3 +124,46 @@ def test_escalated_kill_of_sigterm_ignoring_child_reports_killed(tmp_path, monke
         assert result["status"] == "killed", result
         assert s.completion_reason == "killed"
         assert s.id in reg._finished
    +
    +
    [redacted:email](sys.platform == "win32", reason="POSIX signal escalation; Windows uses taskkill")
    [redacted:email]_system_guard_bypass  # the late children are SIGKILLed after their shell dies
    +def test_kill_reaps_children_spawned_during_the_grace_window(tmp_path, monkeypatch):
    +    """The interactive ``bash -lic`` wrapper ignores SIGTERM and keeps running its script
    +    through the grace window. Children it starts after the kill began must die with it,
    +    not be reparented to init when the shell is finally SIGKILLed."""
    +    import psutil
    +
    +    def _alive(pid):
    +        try:
    +            return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
    +        except psutil.NoSuchProcess:
    +            return False
    +
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +    monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds", staticmethod(lambda: 1.5))
    +    monkeypatch.setattr(ProcessRegistry, "_write_checkpoint", lambda self: None)
    +    started, pid_file = tmp_path / "started", tmp_path / "late_pids"
    +    reg = ProcessRegistry()
    +    s = reg.spawn_local(
    +        f"touch {started}; sleep 0.5; for i in 1 2 3; do sleep 30 & echo $! >> {pid_file}; done; wait",
    +        cwd=str(tmp_path),
    +    )
    +    late = []
    +    try:
    +        deadline = time.monotonic() + 15  # login-shell startup can be slow under load
    +        while not started.exists() and time.monotonic() < deadline:
    +            time.sleep(0.02)
    +        # Kill once the script runs but before the late children exist.
    +        result = reg.kill_process(s.id)
    +        late = [int(p) for p in pid_file.read_text(encoding="utf-8").split()] if pid_file.exists() else []
    +        settle = time.monotonic() + 5  # SIGKILL lands asynchronously on a loaded box
    +        while (survivors := [p for p in late if _alive(p)]) and time.monotonic() < settle:
    +            time.sleep(0.05)
    +    finally:
    +        for p in late:
    +            with suppress(Exception):
    +                psutil.Process(p).kill()
    +    assert len(late) == 3, "the shell never spawned its late children"
    +    assert survivors == [], f"late children orphaned by the kill: {survivors}"
    +    assert result["status"] == "killed", result
    ```
  - `tests/tools/test_mcp_oauth_callback_latch.py`

    ```diff
    @@ -0,0 +1,107 @@
    +"""The loopback OAuth callback latches the first terminal result (#116278).
    +
    +A browser follows the ``/callback`` redirect with queryless fetches (``/favicon.ico``), and the CLI waiter
    +samples the result only every 500 ms. A handler that wrote every GET into the result lost the stored code
    +between two polls, so the user saw "Authorization Successful" while ``hermes mcp login`` timed out. These
    +tests drive the production entry (``_make_callback_waiter`` → ``_start_callback_server`` → handler) with a
    +browser stand-in that sends its requests back-to-back, well inside one poll interval.
    +"""
    +import asyncio
    +import io
    +import socket
    +import threading
    +from http.client import HTTPConnection
    +
    +import pytest
    +
    +pytest.importorskip("mcp.client.auth.oauth2", reason="MCP SDK 1.26.0+ required")
    +
    +import tools.mcp_oauth as mo
    +
    +
    +def _free_port() -> int:
    +    with socket.socket() as s:
    +        s.bind(("127.0.0.1", 0))
    +        return s.getsockname()[1]
    +
    +
    +def _get(port: int, path: str) -> int:
    +    conn = HTTPConnection("127.0.0.1", port, timeout=5)
    +    try:
    +        conn.request("GET", path)
    +        resp = conn.getresponse()
    +        resp.read()
    +        return resp.status
    +    finally:
    +        conn.close()
    +
    +
    +def _wait_listening(port: int) -> None:
    +    for _ in range(200):
    +        try:
    +            with socket.create_connection(("127.0.0.1", port), timeout=0.2):
    +                return
    +        except OSError:
    +            threading.Event().wait(0.02)
    +    raise AssertionError("callback listener never bound")
    +
    +
    +def _drive_waiter(monkeypatch, paths: list[str]):
    +    """Run the real waiter on its own loop; send *paths* back-to-back once the listener is bound.
    +
    +    The waiter polls ``_result_taken`` every 500 ms and closes the listener as soon as the first
    +    terminal callback lands, so on a loaded runner the stand-in's later requests raced a dead port
    +    (``ConnectionRefusedError`` / ``ConnectionResetError``). The waiter's poll is held open until
    +    every request has been answered; the handler's own ``_result_taken`` reads are untouched, which
    +    is what the latch under test relies on."""
    +    monkeypatch.setattr(mo.sys, "stdin", io.StringIO())  # paste reader sees EOF; the HTTP listener is under test
    +    port = _free_port()
    +    out: dict = {}
    +    requests_sent = threading.Event()
    +    real_taken = mo._result_taken
    +
    +    def run():
    +        async def main():
    +            with mo.force_interactive_oauth():
    +                return await mo._make_callback_waiter(port, timeout=30)()
    +        try:
    +            out["result"] = asyncio.run(main())
    +        except Exception as exc:  # noqa: BLE001 — the timeout is the failure under test
    +            out["exc"] = exc
    +
    +    thread = threading.Thread(target=run)
    +
    +    def gated_taken(result):
    +        if threading.current_thread() is thread and not requests_sent.is_set():
    +            return False  # the waiter's poll: keep the listener up until the browser stand-in is done
    +        return real_taken(result)
    +
    +    monkeypatch.setattr(mo, "_result_taken", gated_taken)
    +    thread.start()
    +    _wait_listening(port)
    +    try:
    +        statuses = [_get(port, p) for p in paths]
    +    finally:
    +        requests_sent.set()
    +    thread.join(timeout=15)
    +    assert not thread.is_alive(), "waiter did not finish"
    +    assert "exc" not in out, f"waiter raised {type(out.get('exc')).__name__}"
    +    return statuses, out["result"]
    +
    +
    +def test_favicon_right_after_callback_does_not_clobber_the_code(monkeypatch):
    +    statuses, result = _drive_waiter(
    +        monkeypatch, ["/callback?code=synthetic&state=s1&iss=https://as.example", "/favicon.ico"])
    +    assert statuses == [200, 404]
    +    assert (result.code, result.state, result.iss) == ("synthetic", "s1", "https://as.example")
    +
    +
    +def test_first_terminal_callback_wins_over_later_ones(monkeypatch):
    +    statuses, result = _drive_waiter(monkeypatch, [
    +        "/favicon.ico",
    +        "/callback?code=first&state=s1",
    +        "/callback?code=second&state=s2",
    +        "/callback?error=access_denied&state=s1",
    +    ])
    +    assert statuses == [404, 200, 200, 200]
    +    assert (result.code, result.state) == ("first", "s1")
    ```
  - `tests/tools/test_process_registry.py`

    ```diff
    @@ -408,6 +408,19 @@ def test_reader_loop_reassembles_multibyte_char_split_across_chunks(registry, mo
         assert "\ufffd" not in session.output_buffer
     
     
    +def test_reader_loop_strips_shell_noise_split_across_reads(registry, monkeypatch):
    +    """``bash -lic`` without a tty writes its two startup warnings in two separate write() calls.
    +    A reader that wakes between them (loaded CI) must still drop the second line: it leaked as the
    +    process's only "output", so the dock painted ``last: bash: no job control...`` instead of
    +    ``starting`` and probes waiting on any output woke before the real writer had printed."""
    +    session = _run_reader(registry, monkeypatch, [
    +        b"bash: cannot set terminal process group (7): Inappropriate ioctl for device\n",
    +        b"bash: no job control in this shell\n",
    +        b"real output\n",
    +        b"bash: no job control in this shell\n",  # after real output it is the process's own text
    +    ])
    +    assert session.output_buffer == "real output\nbash: no job control in this shell\n"
    +
     
     
     def test_reader_loop_flushes_truncated_multibyte_tail_at_eof(registry, monkeypatch):
    ```
  - `tests/tools/test_process_registry_list_exit.py`

    ```diff
    @@ -45,7 +45,7 @@ def _probe(root):
                 session.notify_on_complete = True
             owner, sibling = sessions
             deadline = time.monotonic() + 5
    -        while not all(s.output_buffer for s in sessions):
    +        while not all(name + "-output" in s.output_buffer for name, s in zip(("owner", "sibling"), sessions)):
                 assert time.monotonic() < deadline, "writers did not become ready"
                 time.sleep(0.01)
             assert all(s.process.poll() is None for s in sessions)
    ```
  - `tests/tui_gateway/test_compress_session_cwd.py`

    ```diff
    @@ -0,0 +1,36 @@
    +"""Manual /compress rebuilds the system prompt on the RPC thread; it must see the session's workspace.
    +
    +Otherwise the rebuilt prompt records the backend process cwd, and every later resume rejects it as
    +stale runtime and rebuilds it again (a second prompt-cache miss right after the compaction).
    +"""
    +import threading
    +from unittest.mock import MagicMock
    +
    +
    +def test_manual_compress_runs_under_the_session_cwd(tmp_path, monkeypatch):
    +    from agent.runtime_cwd import resolve_agent_cwd
    +    from tui_gateway.server import _compress_session_history
    +
    +    workspace = tmp_path / "workspace"
    +    workspace.mkdir()
    +    monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +    monkeypatch.chdir(tmp_path)
    +    monkeypatch.setattr("tui_gateway.server._get_usage", lambda _agent: {})
    +    history = [{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} for i in range(6)]
    +    seen = []
    +    agent = MagicMock(_cached_system_prompt="", tools=None, session_id="sess-cwd",
    +                      _pending_context_engine_compression_notification=None,
    +                      _compression_skipped_due_to_lock=None)
    +
    +    def _fake_compress(msgs=None, *_args, **_kwargs):
    +        seen.append(resolve_agent_cwd())
    +        return list(msgs or history)[-2:], ""
    +
    +    agent._compress_context.side_effect = _fake_compress
    +    session = {"agent": agent, "history_lock": threading.Lock(), "history": list(history),
    +               "history_version": 1, "running": False, "session_key": "sess-cwd", "cwd": str(workspace)}
    +
    +    _compress_session_history(session)
    +
    +    assert seen == [workspace]
    +    assert resolve_agent_cwd() == tmp_path  # the binding does not leak past the call
    ```
  - `tests/tui_gateway/test_entry_picker_prewarm.py`

    ```diff
    @@ -37,6 +37,7 @@ def _run_main(monkeypatch, events, *, prewarm=None):
         and ``("prewarm",)`` when the spy fires, in call order.
         """
         monkeypatch.setattr(entry, "_install_sidecar_publisher", lambda: None)
    +    monkeypatch.setattr(entry.server, "_stdio_is_rpc_channel", False, raising=False)  # main() flips it; restore after
         monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: None)
         monkeypatch.setattr(entry, "resolve_skin", lambda: "default")
         monkeypatch.setattr(entry.server, "_ensure_skin_watcher", lambda: None)
    ```

    ```diff
    @@ -81,3 +82,11 @@ def _boom():
     
         assert ("prewarm",) in events
         assert ("write", "gateway.ready") in events
    +
    +
    +def test_main_marks_stdout_as_the_rpc_channel(monkeypatch):
    +    """The stdio TUI is the one process whose stdout carries JSON-RPC, so peer-less global
    +    broadcasts (skin.changed, sessions.changed) must still reach it there."""
    +    _run_main(monkeypatch, [])
    +
    +    assert entry.server._stdio_is_rpc_channel is True
    ```
  - `tests/tui_gateway/test_protocol.py`

    ```diff
    @@ -1385,6 +1385,7 @@ def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch):
         server._cfg_cache = server._cfg_sig = server._cfg_path = None
     
         emitted = []
    +    monkeypatch.setattr(server, "_stdio_is_rpc_channel", True)  # stdio TUI: no WS peers, stdout is the client
         monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
     
         # Baseline (default) — seeds the signature.
    ```

    ```diff
    @@ -1409,6 +1410,7 @@ def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch):
         emitted = []
         # switch, no-op, switch, then a color edit (same name, bumped mtime).
         sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)])
    +    monkeypatch.setattr(server, "_stdio_is_rpc_channel", True)  # stdio TUI: no WS peers, stdout is the client
         monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
         monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
         monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs))
    ```

    ```diff
    @@ -1437,10 +1439,11 @@ def close(self) -> None:
             pass
     
     
    -def test_unregister_live_transport_stops_delivery(capture):
    +def test_unregister_live_transport_stops_delivery(capture, monkeypatch):
         """A disconnected peer (unregistered in the ws finally block) receives nothing
         — and a stale write is never attempted against its closed socket."""
         server, buf = capture
    +    monkeypatch.setattr(server, "_stdio_is_rpc_channel", True)  # stdio TUI process
         a = _RecordingTransport()
         server.register_live_transport(a)
         server.unregister_live_transport(a)
    ```

    ```diff
    @@ -1448,7 +1451,7 @@ def test_unregister_live_transport_stops_delivery(capture):
         server._broadcast_global_event("skin.changed", {"name": "x"})
     
         assert a.frames == []
    -    # No live transports left → fell back to stdio.
    +    # No live transports left → fell back to stdio (the stdio TUI's JSON-RPC channel).
         assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed"
     
     
    ```

    ```diff
    @@ -1477,3 +1480,21 @@ def test_approval_for_a_ws_client_that_never_advertised_settles_the_queue_entry(
         assert decision["choice"] is None and decision["cancelled"]
         assert peer.frames == []
         assert "ws-old-approval" not in approval_mod._gateway_queues
    +
    +
    +def test_peerless_global_broadcast_never_reaches_stdout_in_ws_backend(capture, monkeypatch):
    +    """`hermes serve` / dashboard speak JSON-RPC over WS only; Desktop captures their stdout
    +    into desktop.log. After the last WS client leaves, the change watcher keeps ticking —
    +    its sessions.changed / setup.ready / session.reclaimed frames must be dropped, not
    +    printed (~1100 `[hermes] {"jsonrpc": ...}` lines in desktop.log)."""
    +    server, buf = capture
    +    monkeypatch.setattr(server, "_stdio_is_rpc_channel", False, raising=False)
    +    a = _RecordingTransport()
    +    server.register_live_transport(a)
    +    server._broadcast_global_event("sessions.changed", {})
    +    server.unregister_live_transport(a)
    +
    +    server._broadcast_global_event("sessions.changed", {})
    +
    +    assert len(a.frames) == 1
    +    assert buf.getvalue() == ""
    ```
  - `tests/tui_gateway/test_resume_live_lazy_session.py`

    ```diff
    @@ -76,3 +76,32 @@ def test_unscoped_resume_of_profile_session_fails_closed(live_lazy_session):
     def test_unknown_id_still_404s(home):
         out = _resume({"profile": "ops", "session_id": "ghost-9999", "omit_messages": True})
         assert out.get("error", {}).get("code") == 4007
    +
    +
    +class _Agent:
    +    model = "claude-opus-5-5[1m]"
    +    provider = "claude-subscription-directsdk-experimental"
    +
    +
    [redacted:email]("shape", ["override_only", "built_agent", "pending_switch"])
    +def test_live_reattach_reports_the_sessions_own_model_not_the_profile_default(live_lazy_session, monkeypatch, shape):
    +    """A reload of a still-live session must not flip the Desktop picker to the config default. The eager
    +    (cold) resume already reports the chat's own model; the warm reattach reported `_resolve_model()`, so
    +    the pick a user made in the composer appeared to change at random depending on whether the backend had
    +    dropped the session in between."""
    +    sid, record = live_lazy_session
    +    monkeypatch.setattr(srv, "_resolve_model", lambda: "z-ai/glm-5.2")
    +    expected = ("claude-opus-5-5[1m]", "claude-subscription-directsdk-experimental")
    +    if shape == "override_only":
    +        record["model_override"] = {"model": expected[0], "provider": expected[1]}
    +    elif shape == "built_agent":
    +        record["agent"] = _Agent()
    +    else:
    +        record["agent"] = _Agent()
    +        record["pending_model_switch"] = {"display_model": "claude-sonnet-5[1m]", "display_provider": expected[1]}
    +        expected = ("claude-sonnet-5[1m]", expected[1])
    +    out = _resume({"profile": "ops", "session_id": record["session_key"], "omit_messages": True})
    +    assert "error" not in out, out
    +    info = out["result"]["info"]
    +    assert (info["model"], info["provider"]) == expected
    +    assert info["lazy"] is True
    ```
  - `tests/tui_gateway/test_resume_switched_provider_endpoint.py`

    ```diff
    @@ -0,0 +1,35 @@
    +"""A chat switched to another provider must resume on THAT provider's endpoint.
    +
    +The messaging gateway's /model wrote only ``model``/``provider`` over a row the Desktop had persisted
    +with the Nous Portal route, so resuming a chat switched to openai-codex sent the Codex slug to the
    +Portal's chat/completions ("Model 'gpt-6-luna-900k' isn't available on ChatGPT or Codex").
    +"""
    +
    +import json
    +
    +import pytest
    +
    +from hermes_cli.cli_model_switch_mixin import stored_session_route
    +from hermes_state import SessionDB
    +from tui_gateway.server import _stored_session_runtime_overrides
    +
    +NOUS_ROUTE = {"base_url": "https://inference-api.nousresearch.com/v1", "api_mode": "chat_completions"}
    +
    +
    [redacted:email]("row_origin", ["gateway_model_switch", "row_written_by_older_build"])
    +def test_resume_drops_previous_providers_endpoint(tmp_path, row_origin):
    +    db = SessionDB(db_path=tmp_path / "state.db")
    +    db.create_session("s1", source="telegram", model="openai/gpt-6-luna")
    +    if row_origin == "gateway_model_switch":
    +        db.update_session_meta("s1", json.dumps({"model": "openai/gpt-6-luna", "provider": "nous", **NOUS_ROUTE}),
    +                               "openai/gpt-6-luna")
    +        db.update_session_model("s1", "gpt-6-luna-900k", provider="openai-codex")
    +    else:
    +        db.update_session_meta("s1", json.dumps({"model": "gpt-6-luna-900k", "provider": "openai-codex", **NOUS_ROUTE}),
    +                               "gpt-6-luna-900k")
    +    row = db.get_session("s1")
    +
    +    desktop = _stored_session_runtime_overrides(row)["model_override"]
    +    assert (desktop["provider"], desktop["base_url"], desktop["api_mode"]) == ("openai-codex", None, None)
    +    assert stored_session_route(row, current_model="openai/gpt-6-luna", current_provider="nous") == (
    +        "gpt-6-luna-900k", "openai-codex", None, None, True)
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -1022,6 +1022,14 @@ def _wait_for_exit(targets) -> None:
             if grace <= 0:
                 return
             _wait_for_exit(targets)
    +        # A parent that ignored SIGTERM (the interactive ``bash -lic`` wrapper does) keeps
    +        # running its script through both grace windows and can spawn children the first
    +        # snapshot never saw. Re-snapshot while it is still alive: once it is SIGKILLed
    +        # they reparent to init and nothing can find them again.
    +        with suppress(gone):
    +            if cls._proc_alive(parent):
    +                known = {proc.pid for proc in targets}
    +                targets.extend(p for p in parent.children(recursive=True) if p.pid not in known)
             for proc in targets:
                 with suppress(gone):
                     if cls._proc_alive(proc):
    ```

    ```diff
    @@ -1328,7 +1336,10 @@ def _reader_loop(self, session: ProcessSession):
             Windows pipes don't support select(); the blocking path is kept there and the lazy reconcile in
             poll()/wait() remains the safety net. See #68915, #8340.
             """
    -        first_chunk = True
    +        # ``bash -lic`` without a tty writes its startup warnings one write() per line, so the
    +        # reader can wake between them; strip leading noise from every chunk until the
    +        # process has produced real output, not just from the first read.
    +        head_noise = True
             # A split multibyte UTF-8 char would become U+FFFD with stateless decoding; the
             # incremental decoder holds the partial sequence until the rest arrives.
             decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
    ```

    ```diff
    @@ -1339,10 +1350,10 @@ def _reader_loop(self, session: ProcessSession):
             # same treatment the foreground path already has in
             # ``tools/environments/base.py::_wait_for_process``. (Ported from openclaw/openclaw#112325.)
             def _append_chunk(chunk: str):
    -            nonlocal first_chunk
    -            if first_chunk:
    +            nonlocal head_noise
    +            if head_noise:
                     chunk = self._clean_shell_noise(chunk)
    -                first_chunk = False
    +                head_noise = not chunk.strip()
                 self._ingest_output(session, chunk)
             try:
                 proc = session.process
    ```
  - `tui_gateway/entry.py`

    ```diff
    @@ -258,6 +258,8 @@ def _write_or_exit(payload: dict, reason: str) -> None:
     
     
     def main():
    +    # stdout is this process's JSON-RPC client channel: peer-less global broadcasts belong on it.
    +    server._stdio_is_rpc_channel = True
         _close_rpc_stdin_on_exec()
         _install_sidecar_publisher()
     
    ```
  - `tui_gateway/methods_session.py`

    ```diff
    @@ -604,10 +604,14 @@ def _resume_live_unpersisted(ctx: _Resume, live_sid: str, live: dict) -> dict:
             else:
                 _cancel_ws_orphan_reap(live_sid)
         messages = ctx.messages(live.get("history") or [])  # count the wire, as every other resume path does
    +    # The chat's own pick, not the profile default: a warm reattach that reported `_resolve_model()` flipped the
    +    # Desktop picker on every reload while the session was still live, and back once it had been dropped.
    +    model, provider = _live_session_identity(live)
         return _ok(ctx.rid, _attach_todo_state({
             "session_id": live_sid, "stored_session_id": str(live.get("session_key") or ""),
             "message_count": len(messages), "messages": messages,
    -        "info": {"model": _resolve_model(), "lazy": True, "profile_name": profile_name_for_home(live.get("profile_home")) or _response_profile_name(ctx.profile)}}, live))
    +        "info": {"model": model, "provider": provider, "lazy": True,
    +                 "profile_name": profile_name_for_home(live.get("profile_home")) or _response_profile_name(ctx.profile)}}, live))
     
     
     def _resume_adopt_stranded(ctx: _Resume) -> None:
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -668,6 +668,11 @@ def _emit(event: str, sid: str, payload: dict | None = None) -> bool:
     # events, which write_json would otherwise drop on stdio (see _broadcast_global_event).
     _live_transports: set[Transport] = set()
     _live_transports_lock = threading.Lock()
    +# True only when real stdout IS the JSON-RPC client channel (``tui_gateway.entry.main``, the stdio TUI).
    +# `hermes serve` / dashboard processes speak JSON-RPC over WS only: their stdout is captured into
    +# desktop.log, so a peer-less global broadcast (the change watcher keeps ticking after the last WS client
    +# leaves) must be dropped there, not printed.
    +_stdio_is_rpc_channel = False
     
     
     def register_live_transport(transport: Transport | None) -> None:
    ```

    ```diff
    @@ -686,11 +691,15 @@ def unregister_live_transport(transport: Transport | None) -> None:
     
     def _broadcast_global_event(event: str, payload: dict | None = None) -> None:
         """Fan a session-less, surface-global event (``skin.changed``) to every connected client — background
    -    emitters bottom out at stdio in ``write_json``'s ladder. No registered transports (stdio TUI, tests) → ``_emit``."""
    +    emitters bottom out at stdio in ``write_json``'s ladder. No registered transports → ``_emit`` when stdout is the
    +    stdio TUI's JSON-RPC channel, else dropped (nobody is listening; stdout is a log sink)."""
         with _live_transports_lock:
             targets = list(_live_transports)
         if not targets:
    -        return _emit(event, "", payload)
    +        if _stdio_is_rpc_channel:
    +            return _emit(event, "", payload)
    +        logger.debug("global-event broadcast dropped (no connected client) type=%s", event)
    +        return None
         frame = _event_frame(event, "", payload)
         for transport in targets:
             try:
    ```

    ```diff
    @@ -1545,6 +1554,10 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
             provider = billing_provider
         base_url, api_mode, service_tier = field("base_url"), field("api_mode"), field("service_tier")
         reasoning_config = model_config.get("reasoning_config")
    +    from hermes_cli.runtime_provider import is_foreign_provider_endpoint
    +    if is_foreign_provider_endpoint(provider, base_url):
    +        # The endpoint and its wire belong to the provider this chat left; resolve the stored one's own.
    +        base_url = api_mode = ""
         # Heal a stale provider persisted by an older build (renamed/removed custom provider → "Unknown provider"):
         # recover ``custom:<name>`` from the stored base_url, then from the entry serving the model; else drop it.
         if provider and not _is_routable_provider(provider):
    ```

    ```diff
    @@ -2091,6 +2104,21 @@ def _turn_started_at(session: dict | None) -> float | None:
         return float(inflight["started_at"]) if isinstance(inflight, dict) and inflight.get("started_at") else None
     
     
    +def _live_session_identity(session: dict) -> tuple[str, str]:
    +    """``(model, provider)`` the live session actually runs — the same precedence ``_session_info`` reports:
    +    a switch queued mid-turn, the metadata mirror, the built agent, the composer override a deferred record
    +    carries. The profile default is the LAST resort, never the answer for a chat that made its own pick."""
    +    pending = session.get("pending_model_switch") or {}
    +    mirror = _metadata_mirror(session)
    +    agent = session.get("agent")
    +    override = session.get("model_override") or {}
    +    model = (str(pending.get("display_model") or "").strip() or mirror.get("model")
    +             or getattr(agent, "model", "") or override.get("model") or _resolve_model())
    +    provider = (str(pending.get("display_provider") or "").strip() or mirror.get("provider")
    +                or getattr(agent, "provider", "") or override.get("provider") or "")
    +    return str(model), str(provider or "")
    +
    +
     def _session_info(agent, session: dict | None = None) -> dict:
         if session is None:
             session = next((c for c in _sessions.values() if c.get("agent") is agent), None)
    ```
  - `tui_gateway/session_compression.py`

    ```diff
    @@ -242,7 +242,13 @@ def _compress_session_history(
         request = parse_compress_args(focus_topic or "")
         if request.aggressive:
             raise ValueError(AGGRESSIVE_UNSUPPORTED)
    -    result = compress_now(agent, before_messages, request, task_id=session.get("session_key") or "default")
    +    # RPC thread: bind the session cwd, or the boundary prompt rebuild resolves the backend's cwd and
    +    # persists a prompt every other process then rejects as stale runtime (fresh build, no tools pin).
    +    tokens = [redacted:secret-assignment]"session_key") or "", cwd=_session_cwd(session))
    +    try:
    +        result = compress_now(agent, before_messages, request, task_id=session.get("session_key") or "default")
    +    finally:
    +        _clear_session_context(tokens)
         if result.status == "preview":
             return 0, _get_usage(agent)
         # Lock-skipped: raise so callers surface a clear message instead of "No changes from compression".
    ```
  - `website/docs/reference/cli-commands.md`

    ```diff
    @@ -1965,7 +1965,7 @@ Pulls the latest `hermes-agent` code and reinstalls dependencies in the managed
     
     Additional behavior:
     
    -- **Gateway restart.** After a successful update, Hermes attempts to restart all running gateway profiles automatically so they pick up the new code. Use `hermes gateway restart` when you want to restart a gateway without applying an update.
    +- **Gateway restart.** After a successful update, Hermes attempts to restart all running gateway profiles of the home being updated (its root and every `profiles/<name>` under it) automatically so they pick up the new code. Gateways and `hermes-gateway*` services that belong to a different `HERMES_HOME` on the same machine — another install, or a scratch home running `hermes update` — are named in the output and left alone. Use `hermes gateway restart` when you want to restart a gateway without applying an update.
     - **Restart-phase recovery.** If the in-process restart phase aborts while importing the freshly pulled tree, supervised gateway profiles are retried through a clean Python process. Only restarts independently confirmed by systemd (`systemctl --user is-active`) are reported as verified; a relaunch that merely exited 0 is recorded as `relaunch_attempted` and still fails the update conservatively. Manual gateways and serve/dashboard runtimes are never killed without a relaunch authority; they are recorded as skipped with a reason and remain in the incomplete-update report with the exact restart command.
     - **Update receipts + fleet version check.** Every run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/` (pre-update fleet plan, steps, skips with reasons, restart outcome; `latest.json` points at the newest). After the restart phase the updater verifies each live gateway's running code against the updated checkout and prints a per-profile version matrix; a gateway still on pre-update code fails the update (exit 1) with the exact restart command.
     - **Local source changes.** For git installs, dirty tracked files and untracked files are auto-stashed before branch checkout or pull (`git stash push --include-untracked`). Interactive terminal updates ask before restoring the stash. Non-interactive updates restore it by default; set `updates.non_interactive_local_changes: discard` only on managed installs where local source edits should be thrown away after a successful pull. If stash restore conflicts or the pull fails, the stash is left in place for manual recovery.
    ```
  - `website/docs/reference/faq.md`

    ```diff
    @@ -339,7 +339,7 @@ Look at the CLI startup line — it shows the detected context length (e.g., `
     
     **Local servers (llama.cpp, Ollama) that go silent instead of erroring:** when a provider rejects a request as too large, Hermes compacts the conversation and rebuilds the request. Hermes re-measures the *complete* rebuilt request (system prompt + tool schemas + messages) before retrying, and runs further bounded compaction passes if it is still over the threshold. If the request still cannot fit, the turn ends with `Context length exceeded: compression could not reduce the rebuilt request below the safe threshold` rather than sending an oversized request that llama.cpp would silently truncate (`stop processing: n_tokens = [redacted:secret-assignment] truncated = 1` in the server log). If you hit that message, the fix is almost always the configured `context_length` above: make it match the server's actual `-c` / `--ctx-size`.
     
    -**"The model server rejected this request as too large, but this conversation is only about N tokens…":** the server said "context exceeded" without quoting any measurement, while Hermes's own estimate of the request is far below the window it knows for the model — so it does **not** compress or blame the conversation, and the turn stays retryable. On single-slot local servers (LM Studio, Ollama) this is almost always another request holding the server's context at that moment — typically a background memory review from an earlier session (`thread=bg-review` in `logs/agent.log`). Wait a moment and `/retry`. If it recurs with no other Hermes process running, the server is loading the model with a smaller window than Hermes assumes: raise the server's context setting or lower `model.context_length` to match it.
    +**"The model server rejected this request as too large, but this conversation is only about N tokens…":** a local server (localhost, LAN, Tailscale) said "context exceeded" without quoting any measurement, while Hermes's own estimate of the request is far below the window it knows for the model — so it does **not** compress or blame the conversation, and the turn stays retryable. On single-slot local servers (LM Studio, Ollama) this is almost always another request holding the server's context at that moment — typically a background memory review from an earlier session (`thread=bg-review` in `logs/agent.log`). Wait a moment and `/retry`. If it recurs with no other Hermes process running, the server is loading the model with a smaller window than Hermes assumes: raise the server's context setting or lower `model.context_length` to match it. Hosted providers never get this message: they have no shared slot to wait out, so the same rejection there means the route's real window is smaller than Hermes assumes, and Hermes compresses and retries instead.
     
     To fix context detection, set it explicitly:
     
    ```
  - `website/docs/reference/profile-commands.md`

    ```diff
    @@ -80,7 +80,7 @@ Creates a new profile.
     | Argument / Option | Description |
     |-------------------|-------------|
     | `<name>` | Name for the new profile. Must be a valid directory name (alphanumeric, hyphens, underscores). |
    -| `--clone` | Copy `config.yaml`, `.env`, `SOUL.md`, skills, and the curated `memories/MEMORY.md` / `memories/USER.md` from the current profile. Sessions, `state.db` and cron jobs are not copied. |
    +| `--clone` | Copy `config.yaml`, `.env`, `SOUL.md`, skills, the curated `memories/MEMORY.md` / `memories/USER.md`, and the active `memory.provider`'s own config (`<provider>/` or `<provider>.json`, e.g. `hindsight/config.json`) from the current profile. Sessions, `state.db` and cron jobs are not copied. |
     | `--clone-all` | Copy everything (config, memories, skills, plugins) from the current profile. Excludes per-profile history: sessions, `state.db`, backups, state-snapshots, checkpoints — and cron jobs, which stay bound to the source profile (a clone that inherited them would fire every job twice). When the source is the default profile, the machine-scoped local-model trees (`models/`, `runtimes/`, `node/`) are also skipped — the same trees `hermes backup` excludes. |
     | `--clone-from <profile>` | Clone config/skills/SOUL from a specific profile instead of the current one. Implies `--clone` unless paired with `--clone-all`. |
     | `--no-alias` | Skip wrapper script creation. |
    ```
  - `website/docs/user-guide/features/api-server.md`

    ```diff
    @@ -660,6 +660,8 @@ X-Hermes-Session-Key: agent:main:webui:dm:user-42
     
     Rules: max 256 chars, control characters (`\r`, `\n`, `\x00`) are rejected, and the value is echoed back on responses (JSON + SSE). `/v1/capabilities` advertises support via `"session_key_header": "X-Hermes-Session-Key"`. Without the key, Honcho's `per-session` strategy produces a different scope per `session_id` — exactly the behavior Hermes had before.
     
    +Automatic recall follows the **transcript**: the memory provider is initialised once per session and kept across requests, so a continued session (`X-Hermes-Session-Id`, `previous_response_id`, or a declared `X-Hermes-Session-Key` conversation) receives the recall the provider prepared after the previous turn, exactly like a Telegram or Discord chat does. A request without any continuation starts a fresh session and, like the first turn of any new CLI session, has nothing queued yet. Idle sessions release their provider after the same idle TTL as the gateway agent cache (`agent.agent_cache.idle_ttl_secs`, default one hour).
    +
     ## System Prompt Handling
     
     When a frontend sends a `system` message (Chat Completions) or `instructions` field (Responses API), hermes-agent **layers it on top** of its core system prompt. Your agent keeps all its tools, memory, and skills — the frontend's system prompt adds extra instructions.
    ```
  - `website/docs/user-guide/profiles.md`

    ```diff
    @@ -80,7 +80,7 @@ You can also set or auto-generate the description later with `hermes profile des
     hermes profile create work --clone
     ```
     
    -Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
    +Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. If `config.yaml` selects an external memory provider (`memory.provider`), that provider's own config travels too — its `<provider>/` directory or `<provider>.json` under the profile home, e.g. `hindsight/config.json` — so the clone's memory is available instead of silently off; a cloned `local_embedded` hindsight config still shares the source's embedded daemon and bank until you give the clone its own hindsight `profile`/`bank_id` ([#81815](https://github.com/NousResearch/hermes-agent/issues/81815)). Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
     
     #### Keep a clone's imported agent setups synced (`--sync-imports`)
     
    ```
- **#119525: fix(update): refresh a stale editable finder before skipping reinstall**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `0f6bb73f931`

## Fix status

- Merged to main in PR #120127 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-23`.
