diff --git a/apps/desktop/src/plugins/hermes-bots/screen-portal.tsx b/apps/desktop/src/plugins/hermes-bots/screen-portal.tsx index 7afa011f24..07fd89b538 100644 --- a/apps/desktop/src/plugins/hermes-bots/screen-portal.tsx +++ b/apps/desktop/src/plugins/hermes-bots/screen-portal.tsx @@ -119,6 +119,20 @@ export function useScreenPortalState(bot: RosterRow) { [bot, profileKey] ) + // A start/stop/crash made outside this window (CLI, gateway auto-start, another Desktop) is + // pushed by the serve-side runtime watcher; without it the portal's status was one-shot. + useEffect( + () => + host.onEvent('display.status', (event: RpcEvent) => { + const payload = event.payload as DisplayStatus | undefined + + if (payload?.profile_key && isEventForBotScreen(bot, event, profileKey)) { + setScreenStatus(bot, payload) + } + }), + [bot, profileKey] + ) + return { status, lease: state?.lease ?? null, tone: portalTone(status, state?.lease ?? null, state?.viewer ?? null, state?.unavailable) } } diff --git a/tests/tui_gateway/test_display_watch.py b/tests/tui_gateway/test_display_watch.py index b9248724c9..ca7e70a624 100644 --- a/tests/tui_gateway/test_display_watch.py +++ b/tests/tui_gateway/test_display_watch.py @@ -78,3 +78,27 @@ def test_release_in_another_process_is_broadcast_and_local_transition_not_duplic assert _wait_for(lambda: _lease_events(events, holder="agent")), events assert len(_lease_events(events, holder="agent")) == 1 + + +def test_screen_started_or_stopped_by_another_process_is_broadcast_as_status(tmp_path, monkeypatch): + """A start/stop made by the CLI or gateway process must reach an open Desktop: the portal's + status was otherwise one-shot (fetched once, then only lease events).""" + import tui_gateway.server as server + + home = tmp_path / "home" + (home / "bot-desktop").mkdir(parents=True) + events = _watching(server, home, monkeypatch) + server._poll_runtime_files() # seed + assert not [e for e in events if e[0] == "display.status"] + + # what runtime.start() publishes from another process: launcher.pid then env + (home / "bot-desktop" / "launcher.pid").write_text("424242 1.5") + (home / "bot-desktop" / "env").write_text("DISPLAY=:77\n") + server._poll_runtime_files() + statuses = [p for e, p in events if e == "display.status"] + assert statuses and statuses[-1]["profile_key"] == str(home) + + (home / "bot-desktop" / "env").unlink() # stop() from the other process + server._poll_runtime_files() + assert len([e for e in events if e[0] == "display.status"]) == 2 + assert [p for e, p in events if e == "display.status"][-1]["running"] is False diff --git a/tui_gateway/methods_display_watch.py b/tui_gateway/methods_display_watch.py index 3050d08b76..e45af89f79 100644 --- a/tui_gateway/methods_display_watch.py +++ b/tui_gateway/methods_display_watch.py @@ -23,6 +23,9 @@ _lease_watcher_started = threading.Event() # made by THIS process is not re-broadcast when its file write is noticed a tick later). _lease_epochs: dict[str, int] = {} _lease_mtimes: dict[str, int | None] = {} +# profile key → (env mtime, launcher.pid mtime): the screen's running/display identity. A start, +# stop or crash made by another process (CLI, gateway auto-start) moves one of these. +_runtime_marks: dict[str, tuple] = {} def _lease_event_payload(profile_key: str, lease) -> dict: @@ -35,6 +38,34 @@ def _watched_lease_homes() -> list[Path]: return [Path(_hermes_home), *_served_profile_homes] +def _mtime(path: Path): + try: + return path.stat().st_mtime_ns + except OSError: + return None + + +def _poll_runtime_files() -> None: + """Broadcast ``display.status`` when a home's screen started/stopped outside this process.""" + from hermes_constants import hermes_home_key, reset_hermes_home_override, set_hermes_home_override + for home in _watched_lease_homes(): + key = hermes_home_key(home) + sd = home / "bot-desktop" + mark = (_mtime(sd / "env"), _mtime(sd / "launcher.pid")) + first = key not in _runtime_marks + if _runtime_marks.get(key) == mark: + continue + _runtime_marks[key] = mark + if first: # seeding: display.status carries the current state + continue + token = set_hermes_home_override(home) + try: + payload = _display_snapshot() + finally: + reset_hermes_home_override(token) + _broadcast_global_event("display.status", payload) + + def _poll_lease_files() -> None: """One pass: read a home's lease only when its file mtime moved; broadcast when the epoch did.""" from hermes_constants import hermes_home_key @@ -77,6 +108,7 @@ def _ensure_lease_watcher() -> None: while True: try: _poll_lease_files() + _poll_runtime_files() except Exception: # noqa: BLE001 - a torn read must not kill the watcher logger.debug("lease watcher poll failed", exc_info=True) time.sleep(_LEASE_POLL_S)