diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f36d74ad73e..f057bca4f24 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -39,6 +39,7 @@ import { import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' import { clearActiveSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' +import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' import type { RpcEvent } from '@/types/hermes' @@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { requestDesktopOnboarding(payload.credential_warning) } + if (apply) { + reportInstallMethodWarning(payload?.install_warning) + } + void refreshHermesConfig() if (modelChanged || providerChanged) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 254a58e1298..d299fe51b7e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -19,7 +19,7 @@ import { setSessions, setYoloActive } from '@/store/session' -import { reportBackendContract } from '@/store/updates' +import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates' import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes' import type { ClientSessionState } from '../../../types' @@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR requestDesktopOnboarding(info.credential_warning) } + reportInstallMethodWarning(info.install_warning) + if (typeof info.model === 'string') { setCurrentModel(info.model) sessionState.model = info.model diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 67172bc2cad..f368433ac75 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -116,6 +116,7 @@ export const en: Translations = { backendOutOfDateTitle: 'Backend out of date', backendOutOfDateMessage: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.', + installMethodUnsupportedTitle: 'Unsupported install method', updateHermes: 'Update Hermes', updateReadyTitle: 'Update ready', updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 7c23fb65601..989fa47ab87 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -117,6 +117,7 @@ export const ja = defineLocale({ backendOutOfDateTitle: 'バックエンドが古いです', backendOutOfDateMessage: 'Hermes バックエンドがこのデスクトップビルドより古く、正常に動作しない場合があります。更新して揃えてください。', + installMethodUnsupportedTitle: 'サポート対象外のインストール方法', updateHermes: 'Hermes を更新', updateReadyTitle: '更新の準備ができました', updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 7bf02671436..6e6e8c1debd 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -158,6 +158,7 @@ export interface Translations { copyDetailFailed: string backendOutOfDateTitle: string backendOutOfDateMessage: string + installMethodUnsupportedTitle: string updateHermes: string updateReadyTitle: string updateReadyMessage: (count: number) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5859569a2ae..6ce5c0d58e4 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -113,6 +113,7 @@ export const zhHant = defineLocale({ copyDetailFailed: '無法複製通知詳情', backendOutOfDateTitle: '後端版本過舊', backendOutOfDateMessage: '您的 Hermes 後端早於目前的桌面版本,可能無法正常運作。請更新以保持一致。', + installMethodUnsupportedTitle: '不受支援的安裝方式', updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 項新變更可用。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4c0b14150f9..6eba08ef5c5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -113,6 +113,7 @@ export const zh: Translations = { copyDetailFailed: '无法复制通知详情', backendOutOfDateTitle: '后端版本过旧', backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。', + installMethodUnsupportedTitle: '不受支持的安装方式', updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 项新更改可用。`, diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 317108a9955..2b232ad3b19 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -50,6 +50,7 @@ export type GatewayEventPayload = { cwd?: string branch?: string credential_warning?: string + install_warning?: string personality?: string usage?: Partial // agent.terminal.output — live chunk for a read-only agent terminal tab diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index f9e76333c63..7c51c8aab32 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -111,6 +111,24 @@ function isSkewToastSnoozed(): boolean { return Number.isFinite(until) && Date.now() < until } +const INSTALL_METHOD_TOAST_ID = 'install-method-not-supported' +// Same time-based snooze pattern as the update/skew toasts: the warning is +// re-derived from every session.info (session.create/resume/activate all +// route through applyRuntimeInfo), so without a snooze it would re-pop on +// every session switch even right after the user dismissed it. +const INSTALL_METHOD_TOAST_SNOOZE_KEY = 'hermes:install-method-toast-snooze-until' +const INSTALL_METHOD_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000 + +function snoozeInstallMethodToast(): void { + persistString(INSTALL_METHOD_TOAST_SNOOZE_KEY, String(Date.now() + INSTALL_METHOD_TOAST_COOLDOWN_MS)) +} + +function isInstallMethodToastSnoozed(): boolean { + const until = Number(storedString(INSTALL_METHOD_TOAST_SNOOZE_KEY) || 0) + + return Number.isFinite(until) && Date.now() < until +} + /** * Guard against a desktop GUI talking to a backend that predates its contract * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing @@ -151,6 +169,27 @@ export function reportBackendContract(contract: number | undefined): void { }) } +export function reportInstallMethodWarning(message: string | undefined): void { + if (!message) { + dismissNotification(INSTALL_METHOD_TOAST_ID) + + return + } + + if (isInstallMethodToastSnoozed()) { + return + } + + notify({ + durationMs: 0, + id: INSTALL_METHOD_TOAST_ID, + kind: 'warning', + message, + onDismiss: () => snoozeInstallMethodToast(), + title: translateNow('notifications.installMethodUnsupportedTitle') + }) +} + /** * Fire a toast when an update is available, at most once per cooldown window. * Closing the toast — dismissing it or opening the updates window from it — diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index e360668c14b..3515477d8f7 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -399,6 +399,7 @@ export interface SessionRuntimeInfo { cwd?: string desktop_contract?: number fast?: boolean + install_warning?: string model?: string personality?: string provider?: string diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 217eb2bb965..a596bc91d70 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -2,7 +2,6 @@ Pure display functions with no HermesCLI state dependency. """ - import json import logging import os @@ -322,7 +321,7 @@ def check_for_updates() -> Optional[int]: # both the Rich banner (build_welcome_banner) and the Ink badge # (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing. try: - from hermes_cli.config import detect_install_method + from hermes_cli.config import detect_install_method, get_project_root if detect_install_method() == "docker": return None except Exception: @@ -873,17 +872,23 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, except Exception: pass # Never break the banner over an update check - # Pip-install warning — `pip install hermes-agent` is not the supported - # install path (it exists on PyPI for internal/CI reasons, not end users). - # Such installs miss the git checkout + installer-managed deps, so updates, - # self-update, and issue triage don't behave correctly. Warn, don't block. + # Unsupported install-method warning — pip/PyPI and Homebrew are no + # longer an officially supported distribution method (see + # website/docs/getting-started/platform-support.md). Such installs miss + # the git checkout + installer-managed deps, so updates, self-update, and + # issue triage don't behave correctly. Warn, don't block. NixOS is fully + # supported and never hits this. try: - from hermes_cli.config import detect_install_method - if detect_install_method() == "pip": + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + get_project_root + ) + _install_method = detect_install_method() + if is_unsupported_install_method(_install_method): right_lines.append( - "[bold yellow]⚠ pip install not officially supported[/]" - "[dim yellow] — exists for reasons other than user install; " - "expect instability and an inability to support issues[/]" + f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]" ) except Exception: pass # Never break the banner over the install-method check diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 797f4d0977e..fadca5457fa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -530,6 +530,50 @@ def recommended_update_command() -> str: return recommended_update_command_for_method(method) +# ============================================================================= +# Unsupported install methods (pip, Homebrew) — deprecation notice +# ============================================================================= +# +# pip/PyPI and Homebrew are NOT an officially supported distribution method +# (see website/docs/getting-started/platform-support.md, "Unsupported" +# section). pip exists on PyPI for internal/CI reasons, not end-user installs; +# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode" +# (which hard-blocks config writes), this is a warn-don't-block deprecation +# notice surfaced everywhere the user might see install-method state: the CLI +# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS +# stays fully supported (Tier 2) and must never hit this path. + +PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support" + +_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"}) + + +def is_unsupported_install_method(method: str) -> bool: + """Whether ``method`` (from ``detect_install_method()``) is deprecated.""" + return method in _UNSUPPORTED_INSTALL_METHODS + + +def unsupported_install_method_label(method: str) -> str: + """Human-readable name for an unsupported install method.""" + return "pip" if method == "pip" else "Homebrew" + + +def format_unsupported_install_warning(method: str) -> str: + """Plain-text (no markup) deprecation notice for pip/Homebrew installs. + + Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and + ``hermes update`` / ``hermes update --check`` so the wording — and the + docs link — stays consistent across every surface instead of drifting + into three slightly different warnings. + """ + label = unsupported_install_method_label(method) + return ( + f"{label} installs are no longer an officially supported platform and " + f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} " + "for supported install methods." + ) + + # Long-form text for ``hermes update`` / ``--check`` when running inside the # Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in # hermes_cli/main.py; lives here so the wording stays consistent and we diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4e483d318c3..269d6d609ea 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8375,8 +8375,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): on a PyPI install we surface a one-line notice instead of silently dropping the flag. """ - from hermes_cli.config import detect_install_method + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + ) method = detect_install_method(PROJECT_ROOT) + if is_unsupported_install_method(method): + print(f"⚠ {format_unsupported_install_warning(method)}") if method == "docker": # Docker can't ``git fetch`` from within the container. Surface the # same long-form ``docker pull`` guidance ``hermes update`` (apply @@ -9259,10 +9265,21 @@ def cmd_update(args): from hermes_cli.config import ( detect_install_method, format_docker_update_message, + format_unsupported_install_warning, is_managed, + is_unsupported_install_method, managed_error, ) + # Deprecation notice for pip/Homebrew installs — printed before the + # managed-mode early-return below so Homebrew users (who are blocked from + # applying the update here) still see it. Warn, don't block: the update + # itself still proceeds (except Homebrew, which is managed-mode blocked + # for an unrelated reason — brew owns its own upgrade path). + _install_method_for_warning = detect_install_method(PROJECT_ROOT) + if is_unsupported_install_method(_install_method_for_warning): + print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}") + if is_managed(): managed_error("update Hermes Agent") return diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index 673ea568759..c852b57052a 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -195,7 +195,33 @@ def test_banner_warns_on_pip_install(tmp_path): out = buf.getvalue() assert "officially" in out - assert "instability" in out + assert "platform-support" in out + + +def test_banner_warns_on_homebrew_install(tmp_path): + """The welcome banner surfaces a warning when the install method is homebrew.""" + import io + from rich.console import Console + from hermes_cli import banner + + hh = tmp_path / ".hermes" + hh.mkdir() + (hh / ".install_method").write_text("homebrew\n") + + with patch("hermes_cli.config.get_hermes_home", return_value=hh), \ + patch("hermes_constants.get_hermes_home", return_value=hh): + buf = io.StringIO() + console = Console(file=buf, width=400, force_terminal=False, color_system=None) + banner.build_welcome_banner( + console, model="m", cwd="/tmp", + tools=[{"function": {"name": "terminal"}}], + enabled_toolsets=["terminal"], + ) + out = buf.getvalue() + + assert "officially" in out + assert "Homebrew" in out + assert "platform-support" in out def test_banner_no_pip_warning_on_git_install(tmp_path): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6d39a252cfe..5a2c222a0fd 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4610,6 +4610,25 @@ def test_session_info_includes_session_title(monkeypatch): assert info["title"] == "Dashboard title" +def test_session_info_includes_install_warning_for_pip(monkeypatch): + """pip installs surface install_warning; git installs don't (issue: pip/brew deprecation).""" + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "pip") + + info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) + + assert "install_warning" in info + assert "pip" in info["install_warning"] + assert "platform-support" in info["install_warning"] + + +def test_session_info_omits_install_warning_for_git(monkeypatch): + monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "git") + + info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) + + assert "install_warning" not in info + + # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 878b28d02c2..2a261c2cc89 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3159,6 +3159,18 @@ def _session_info(agent, session: dict | None = None) -> dict: "usage": _get_usage(agent), "profile_name": _current_profile_name(), } + try: + from hermes_cli.config import ( + detect_install_method, + format_unsupported_install_warning, + is_unsupported_install_method, + ) + + _install_method = detect_install_method() + if is_unsupported_install_method(_install_method): + info["install_warning"] = format_unsupported_install_warning(_install_method) + except Exception: + pass try: from hermes_cli import __version__, __release_date__ diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 136b97db90c..3c9cdd5f251 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -412,6 +412,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { )} + + {info.install_warning && ( + + ! {info.install_warning} + + )} ) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4f7ffa225d2..14ab98ca18d 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -150,6 +150,7 @@ export interface McpServerStatus { export interface SessionInfo { cwd?: string fast?: boolean + install_warning?: string lazy?: boolean mcp_servers?: McpServerStatus[] model: string