Hermes Desktop: «The in-app browser only takes actions in the session the user is looking at» — por qué pasa y cómo lo arreglé

TL;DR — Si el agente de Hermes Desktop te responde «The in-app browser only takes actions in the session the user is looking at» aunque sí estás viendo ese chat, revisa si el chat está abierto como tile (pestaña lateral). El filtro que protege al navegador integrado compara contra la sesión principal de la ventana, no contra la tile enfocada, así que la rechaza. Atajo sin código: abre ese chat como vista principal. Arreglo real: dejar pasar también a la tile enfocada (y solo a ella). Abajo está el parche con sus pruebas.


1. El síntoma

Tenía un chat en Hermes ayudándome con un trámite en una página web, dentro del navegador integrado (el panel que sale a la derecha del chat). El agente podía leer la página sin problema, pero en cuanto intentaba hacer algo —listar los elementos, hacer clic, escribir en un campo— respondía:

The in-app browser only takes actions in the session the user is looking at.

Lo raro: yo sí estaba viendo esa conversación. Estaba enfocada, en pantalla, con la página abierta al lado.

La pista clave: leer funcionaba y actuar no. Eso descarta permisos del sitio, sesión de Google o privilegios de administrador. El bloqueo tenía que estar en Hermes, antes de tocar la página.

2. El recorrido de una acción

Cuando el agente usa drive_preview, la petición viaja así:

  1. tools/drive_preview_tool.py valida los parámetros y llama a la plataforma.
  2. gateway/local_desktop_ui.py manda preview.act a las ventanas de escritorio conectadas.
  3. gateway/session_server_requests.py le pega el session_id de la conversación.
  4. En el escritorio, handleServerRequest (en server-requests.ts) decide qué ventana responde y calcula isActiveSession.
  5. previewAct rechaza si isActiveSession es falso. El clic nunca llega a la página.

3. La causa: dos ideas distintas de «la sesión que estás viendo»

Hermes deja abrir varias conversaciones en la misma ventana como tiles. Y tiene dos estados que suenan igual pero no lo son:

Estado Qué significa ¿Cambia al hacer clic en una tile?
activeSessionId La sesión principal de la ventana No
$focusedRuntimeId La sesión que tienes enfocada (primaria o tile) Sí

El propio código lo confirma: focusOpenSession trae la tile al frente sin cambiar la identidad primaria, y hay una prueba (host-state.test.ts) que afirma justo eso.

El problema es que el filtro de previewAct usa isActiveSession, que se calcula contra activeSessionId. Entonces:

  • La petición de la tile pasa el filtro de ventana (windowHostsSession acepta tiles abiertas).
  • Pero choca con el filtro de sesión, que solo reconoce a la primaria.

Resultado: lectura sí, acción no. Justo el síntoma.

4. Lo que NO hay que hacer

La tentación es poner isActiveSession = true o quitar el if. No. Ese filtro existe por una buena razón: que el agente de otro chat que corre de fondo no te mueva la página que estás usando. El comentario del código lo dice claro: «offer, don’t hijack».

El arreglo tiene que abrirle la puerta solo a la tile que el usuario tiene enfocada, y dejar cerradas:

  • las tiles abiertas pero sin foco (turnos de fondo),
  • las sesiones de otros perfiles,
  • las ramas distintas de una misma conversación.

5. El parche

En apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts:

import {
  $focusedRuntimeId,
  $sessionStates,
  $sessionTiles,
  // …
} from '@/store/session-states'

/**
 * The request names the tile the user focused. Focusing a tile moves
 * $focusedRuntimeId but leaves the primary's activeSessionId in place, so the
 * strict active check refused the very chat the user is looking at. A tile
 * that is open but not focused is still a background turn and stays refused.
 */
function requestNamesFocusedTile(sessionId: string, deps: ServerRequestContext['deps']): boolean {
  const focused = $focusedRuntimeId.get()

  if (!sessionId || !focused || !$sessionTiles.get().some(tile => tile.runtimeId === focused)) {
    return false
  }

  return requestNamesActiveSession({
    activeSessionId: focused,
    sessionId,
    storedIdForRuntimeId: runtimeId => deps.sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId ?? undefined
  })
}

Y en previewAct el filtro pasa de esto:

if (!isActiveSession) {

a esto:

if (!isActiveSession && !requestNamesFocusedTile(sessionId, deps)) {

Detalles que importan:

  • Es aditivo. La sesión principal se comporta exactamente igual que antes (ya había una prueba que la deja actuar aunque una tile tenga el foco).
  • Exige que el foco sea una tile de verdad ($sessionTiles), no cualquier id.
  • Reutiliza requestNamesActiveSession, que ya resuelve los ids que cambian cuando Hermes comprime la conversación (el id de runtime rota, el almacenado no).
  • La página que se maneja sigue siendo la de la sesión que pide (previewOwnerFor(sessionId)), así que una tile no puede meterse en las pestañas de otra.

6. Las pruebas

Agregué dos casos a server-requests.test.ts:

it('lets the focused tile drive its own page', async () => {
  openPreview({ kind: 'url', label: 'b', source: 'https://b.example', url: 'https://b.example' }, 'stored-b')
  const back = vi.fn()
  const unbind = registerPreviewNav($previewTabs.get()[0]!.id, { back, forward: () => {}, reload: () => {} })

  focusTileB()

  try {
    const { respond } = deliver('preview.act', { action: 'back', session_id: 'rt-b' }, 'rt-a')

    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1), { timeout: 30_000 })
    expect(JSON.parse(respond.mock.calls[0][0].value)).toMatchObject({ acted: 'back', success: true })
    expect(back).toHaveBeenCalledTimes(1)
  } finally {
    unbind()
  }
})

it('still refuses an open tile that does not hold focus', () => {
  const { respond } = deliver('preview.act', { action: 'back', session_id: 'rt-b' }, 'rt-a')

  expect(JSON.parse(respond.mock.calls[0][0].value)).toMatchObject({
    error: 'The in-app browser only takes actions in the session the user is looking at.',
    success: false
  })
})
cd apps/desktop
npm exec -- vitest run src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts
# Test Files  1 passed (1)
#      Tests  36 passed (36)

Y la comprobación que hace que la prueba valga: sin el parche, lets the focused tile drive its own page falla (35 de 36). O sea, la prueba reproduce el bug real; no es de adorno.

7. Reconstruir la app

Si instalaste Hermes desde el código (la app corre desde apps/desktop/release/win-unpacked/Hermes.exe), el cambio en src/ no se aplica solo. Hay que reconstruir:

  1. Cierra Hermes Desktop (Windows bloquea los archivos de una app abierta).

  2. Desde una terminal fuera de Hermes:

    hermes desktop --build-only
    

    Hermes detecta que el código cambió, construye en una carpeta aparte y solo reemplaza la app si todo salió bien.

  3. Abre Hermes otra vez.

Para confirmar que el ejecutable nuevo trae el cambio, busca el mensaje de error en el bundle compilado; junto a él debe aparecer la segunda condición:

grep -o ".\{120\}only takes actions in the session the user is looking at" \
  apps/desktop/release/win-unpacked/resources/app.asar.unpacked/dist/assets/index-*.js
# …if(!t&&!UTt(r,e)){…   ← la segunda condición es el parche

8. Probarlo de verdad

Con el chat abierto como tile y enfocado, pídele al agente algo que no envíe nada:

Prueba el navegador integrado sin enviar nada:
1. Usa drive_preview con action=elements (max=10) en la página que tengo abierta.
2. Dime qué campos y botones ves.
3. NO llenes ni envíes ningún formulario.

Si te dice «No live page is open in the in-app browser — open one with open_preview first», buena señal: ya pasó el filtro de sesión y solo falta abrir la página (por ejemplo, tras reiniciar Hermes se cierran). Pídele que la abra con open_preview y repite.

En mi caso devolvió los 10 elementos de la página sin el error de sesión.

9. Si no puedes (o no quieres) tocar código

Abre el chat como vista principal en lugar de tile. Ahí activeSessionId sí es ese chat y drive_preview funciona desde el primer intento.

10. Advertencia sobre actualizaciones

Este es un parche local. Cuando Hermes se actualice, el cambio se puede perder o chocar con lo que baje de upstream. Si después de actualizar vuelve el error:

  1. Revisa si upstream ya lo corrigió.
  2. Si no, vuelve a aplicar el git diff y reconstruye.

Resumen en una línea: el navegador integrado de Hermes confundía «la sesión principal» con «la sesión que estás viendo»; dejar pasar a la tile enfocada (y solo a ella) lo arregla sin abrirle la puerta a los chats de fondo.

Me quedé sin créditos en Netlify y migré mi blog a Cloudflare Pages gratis (en una tarde)
Older post

Me quedé sin créditos en Netlify y migré mi blog a Cloudflare Pages gratis (en una tarde)

Netlify Free agotó sus 300 créditos y dejó de publicar mi blog. Lo pasé a Cloudflare Pages sin pagar nada: funciones, paywall cifrado, formularios con WhatsApp y dominio, probando todo antes de tocar producción. Te cuento el paso a paso y las trampas en las que caí.

Newer post

Cómo darte de baja de boletines en Gmail (uno por uno o en lote)

Paso a paso para limpiar tu bandeja con «Gestionar suscripciones» de Gmail: dónde está, cómo decidir qué se queda y un script para dar de baja decenas de boletines de una sola vez.

Cómo darte de baja de boletines en Gmail (uno por uno o en lote)
raul@wesoft: ~/ficha