From 407a11b4190d7a6ebbc6429d0481545abd86aadc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 20:25:47 -0700 Subject: [PATCH 01/41] feat(discord): allow_any_attachment config to accept arbitrary file types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord adapter silently dropped any attachment whose extension wasn't in the SUPPORTED_DOCUMENT_TYPES allowlist (PDF, text family, zip, office). Users uploading .wav / .bin / other unrecognized formats saw nothing in their conversation — the file got logged as 'Unsupported document type' and discarded before the agent ever saw it. Add discord.allow_any_attachment (default false) to bypass the allowlist. When on: - Any file is downloaded, cached under ~/.hermes/cache/documents/, and surfaced as a DOCUMENT-typed event with application/octet-stream MIME - gateway/run.py already emits a context note with the cached path, auto-translated via to_agent_visible_cache_path() for Docker/Modal sandboxed terminals - File body is NOT inlined — only the path — so binary uploads don't blow up the context window - Allowlisted text formats (.txt/.md/.log) keep their 100 KiB inline behavior unchanged Also adds discord.max_attachment_bytes (default 32 MiB matches the historical hardcoded cap; 0 = unlimited) since users opting into arbitrary types may want to raise the cap. The whole attachment is held in memory while being cached, so unlimited carries a real memory cost. Env overrides: DISCORD_ALLOW_ANY_ATTACHMENT, DISCORD_MAX_ATTACHMENT_BYTES. Discord-only by deliberate scope. Telegram has hard 20 MB API limits and Slack has its own caps — extending the same flag there is a separate follow-up if/when requested. --- gateway/platforms/discord.py | 88 +++++++++-- hermes_cli/config.py | 12 ++ .../gateway/test_discord_document_handling.py | 145 ++++++++++++++++++ .../docs/reference/environment-variables.md | 2 + website/docs/user-guide/messaging/discord.md | 21 +++ 5 files changed, 258 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index a3904630fa9..9b8285e2a36 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3564,6 +3564,43 @@ class DiscordAdapter(BasePlatformAdapter): return bool(configured) return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} + def _discord_allow_any_attachment(self) -> bool: + """Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist. + + When True, any uploaded file is cached to disk and surfaced to the + agent as a local path so it can be inspected via terminal / read_file + / ffprobe / etc. Default False preserves the historical behaviour of + dropping unsupported types with a warning log. + """ + configured = self.config.extra.get("allow_any_attachment") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in {"false", "0", "no", "off", ""} + return bool(configured) + return os.getenv("DISCORD_ALLOW_ANY_ATTACHMENT", "false").lower() in {"true", "1", "yes", "on"} + + def _discord_max_attachment_bytes(self) -> int: + """Return the per-attachment byte cap. 0 means unlimited. + + The whole attachment is held in memory while being written to the + cache, so unlimited carries a real memory cost. Default 32 MiB + matches the historical hardcoded value. + """ + configured = self.config.extra.get("max_attachment_bytes") + if configured is None: + configured = os.getenv("DISCORD_MAX_ATTACHMENT_BYTES") + if configured is None or configured == "": + return 32 * 1024 * 1024 + try: + value = int(configured) + except (TypeError, ValueError): + logger.warning( + "[Discord] Invalid max_attachment_bytes value %r, falling back to 32 MiB", + configured, + ) + return 32 * 1024 * 1024 + return max(0, value) + def _discord_free_response_channels(self) -> set: """Return Discord channel IDs where no bot mention is required. @@ -4495,6 +4532,7 @@ class DiscordAdapter(BasePlatformAdapter): if normalized_content.startswith("/"): msg_type = MessageType.COMMAND elif all_attachments: + _allow_any = self._discord_allow_any_attachment() # Check attachment types for att in all_attachments: if att.content_type: @@ -4509,9 +4547,15 @@ class DiscordAdapter(BasePlatformAdapter): if att.filename: _, doc_ext = os.path.splitext(att.filename) doc_ext = doc_ext.lower() - if doc_ext in SUPPORTED_DOCUMENT_TYPES: + if doc_ext in SUPPORTED_DOCUMENT_TYPES or _allow_any: msg_type = MessageType.DOCUMENT break + elif _allow_any: + # No content_type at all (rare — discord usually fills it + # in). Treat as a document so downstream pipelines surface + # the path to the agent. + msg_type = MessageType.DOCUMENT + break # When auto-threading kicked in, route responses to the new thread effective_channel = auto_threaded_channel or message.channel @@ -4594,31 +4638,48 @@ class DiscordAdapter(BasePlatformAdapter): if not ext and content_type: mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} ext = mime_to_ext.get(content_type, "") - if ext not in SUPPORTED_DOCUMENT_TYPES: + allow_any_attachment = self._discord_allow_any_attachment() + in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES + if not in_allowlist and not allow_any_attachment: logger.warning( "[Discord] Unsupported document type '%s' (%s), skipping", ext or "unknown", content_type, ) else: - MAX_DOC_BYTES = 32 * 1024 * 1024 - if att.size and att.size > MAX_DOC_BYTES: + max_doc_bytes = self._discord_max_attachment_bytes() + if max_doc_bytes and att.size and att.size > max_doc_bytes: logger.warning( - "[Discord] Document too large (%s bytes), skipping: %s", - att.size, att.filename, + "[Discord] Document too large (%s bytes > cap %s), skipping: %s", + att.size, max_doc_bytes, att.filename, ) else: try: raw_bytes = await self._cache_discord_document(att, ext) cached_path = cache_document_from_bytes( - raw_bytes, att.filename or f"document{ext}" + raw_bytes, att.filename or f"document{ext or '.bin'}" ) - doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + if in_allowlist: + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + else: + # allow_any_attachment path: untyped file. Use the + # source content_type if discord gave us one, + # otherwise fall back to octet-stream so the agent + # knows it's binary and reaches for terminal tools. + doc_mime = ( + content_type + if content_type and content_type != "unknown" + else "application/octet-stream" + ) media_urls.append(cached_path) media_types.append(doc_mime) - logger.info("[Discord] Cached user document: %s", cached_path) + logger.info( + "[Discord] Cached user %s: %s", + "document" if in_allowlist else "attachment", + cached_path, + ) # Inject text content for plain-text documents (capped at 100 KB) MAX_TEXT_INJECT_BYTES = 100 * 1024 - if ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if in_allowlist and ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") display_name = att.filename or f"document{ext}" @@ -4630,6 +4691,13 @@ class DiscordAdapter(BasePlatformAdapter): pending_text_injection = injection except UnicodeDecodeError: pass + # NOTE: for the allow_any_attachment path we deliberately + # do NOT inject a path string here. ``gateway/run.py`` + # already detects DOCUMENT-typed events with + # ``application/octet-stream`` MIME and emits a context + # note with the sandbox-translated cache path via + # ``to_agent_visible_cache_path()`` (important for + # Docker/Modal terminal backends). except Exception as e: logger.warning( "[Discord] Failed to cache document %s: %s", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c1f68e1c88c..c41158e42ae 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1306,6 +1306,18 @@ DEFAULT_CONFIG = { # list_roles, member_info, search_members, fetch_messages, list_pins, # pin_message, unpin_message, create_thread, add_role, remove_role. "server_actions": "", + # Accept arbitrary attachment file types (not just SUPPORTED_DOCUMENT_TYPES). + # When True, any uploaded file is cached to disk with mime + # application/octet-stream and the path is surfaced to the agent so it + # can use terminal/read_file/etc. against it. Default False preserves + # the historical allowlist behaviour. + # Env override: DISCORD_ALLOW_ANY_ATTACHMENT. + "allow_any_attachment": False, + # Maximum bytes per attachment the gateway will cache. The whole file + # is held in memory while being written, so unlimited uploads carry a + # real memory cost. Default 32 MiB matches the historical hardcoded + # cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES. + "max_attachment_bytes": 33554432, }, # WhatsApp platform settings (gateway mode) diff --git a/tests/gateway/test_discord_document_handling.py b/tests/gateway/test_discord_document_handling.py index d3ad137b61c..0685b69663a 100644 --- a/tests/gateway/test_discord_document_handling.py +++ b/tests/gateway/test_discord_document_handling.py @@ -384,3 +384,148 @@ class TestIncomingDocumentHandling: assert event.message_type == MessageType.PHOTO assert event.media_urls == ["/tmp/cached_image.png"] assert event.media_types == ["image/png"] + + +class TestAllowAnyAttachment: + """Cover the discord.allow_any_attachment config flag. + + With the flag off (default), unknown file types are dropped. With it on, + they get cached and surfaced to the agent as DOCUMENT events with + application/octet-stream MIME so gateway/run.py emits a path-pointing + context note. + """ + + @pytest.mark.asyncio + async def test_unknown_type_skipped_by_default(self, adapter): + """Default (flag off): unknown extension is dropped. + + With no text + no cached media, the adapter may legitimately decline + to dispatch the event at all, so we don't assert on call_args here — + we just verify the file wasn't cached. + """ + with _mock_aiohttp_download(b"should not be cached"): + msg = make_message([ + make_attachment(filename="weird.xyz", content_type="application/x-custom") + ]) + await adapter._handle_message(msg) + + if adapter.handle_message.call_args is not None: + event = adapter.handle_message.call_args[0][0] + assert event.media_urls == [] + + @pytest.mark.asyncio + async def test_unknown_type_cached_when_flag_on(self, adapter): + """Flag on: unknown extension is cached as application/octet-stream.""" + adapter.config.extra["allow_any_attachment"] = True + + with _mock_aiohttp_download(b"\x00\x01\x02 binary payload"): + msg = make_message([ + make_attachment(filename="weird.xyz", content_type="application/x-custom") + ]) + await adapter._handle_message(msg) + + event = adapter.handle_message.call_args[0][0] + assert len(event.media_urls) == 1 + assert os.path.exists(event.media_urls[0]) + # Falls back to the source content_type when we have one. + assert event.media_types == ["application/x-custom"] + assert event.message_type == MessageType.DOCUMENT + # We deliberately do NOT inline arbitrary bytes — run.py emits the + # path-pointing note based on DOCUMENT + octet-stream MIME. + assert "[Content of" not in (event.text or "") + + @pytest.mark.asyncio + async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter): + """Flag on + no content_type from discord: MIME falls back to octet-stream.""" + adapter.config.extra["allow_any_attachment"] = True + + with _mock_aiohttp_download(b"raw bytes"): + msg = make_message([ + make_attachment(filename="mystery.bin", content_type=None) + ]) + await adapter._handle_message(msg) + + event = adapter.handle_message.call_args[0][0] + assert event.message_type == MessageType.DOCUMENT + assert event.media_types == ["application/octet-stream"] + + @pytest.mark.asyncio + async def test_max_attachment_bytes_caps_uploads(self, adapter): + """discord.max_attachment_bytes overrides the historical 32 MiB cap.""" + adapter.config.extra["allow_any_attachment"] = True + adapter.config.extra["max_attachment_bytes"] = 1024 # 1 KiB + + msg = make_message([ + make_attachment( + filename="too_big.xyz", + content_type="application/x-custom", + size=2048, + ) + ]) + await adapter._handle_message(msg) + + event = adapter.handle_message.call_args[0][0] + assert event.media_urls == [] + + @pytest.mark.asyncio + async def test_max_attachment_bytes_zero_means_unlimited(self, adapter): + """max_attachment_bytes=0 disables the size cap entirely.""" + adapter.config.extra["allow_any_attachment"] = True + adapter.config.extra["max_attachment_bytes"] = 0 + + # 64 MiB — would normally exceed the historical 32 MiB hardcoded cap. + with _mock_aiohttp_download(b"x" * 16): + msg = make_message([ + make_attachment( + filename="huge.xyz", + content_type="application/x-custom", + size=64 * 1024 * 1024, + ) + ]) + await adapter._handle_message(msg) + + event = adapter.handle_message.call_args[0][0] + assert len(event.media_urls) == 1 + + @pytest.mark.asyncio + async def test_allowlisted_doc_unchanged_when_flag_on(self, adapter): + """Flag on must not change handling of types already in SUPPORTED_DOCUMENT_TYPES. + + A .txt should still get its content inlined (the historical behavior), + and the MIME should still be the canonical text/plain — not whatever + discord guessed. + """ + adapter.config.extra["allow_any_attachment"] = True + file_content = b"still a text file" + + with _mock_aiohttp_download(file_content): + msg = make_message( + attachments=[make_attachment(filename="notes.txt", content_type="text/plain")], + content="check this", + ) + await adapter._handle_message(msg) + + event = adapter.handle_message.call_args[0][0] + assert "[Content of notes.txt]:" in event.text + assert "still a text file" in event.text + assert event.media_types == ["text/plain"] + + def test_helper_reads_env_fallback(self, adapter, monkeypatch): + """Helper falls back to DISCORD_ALLOW_ANY_ATTACHMENT env var.""" + assert adapter._discord_allow_any_attachment() is False + monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true") + assert adapter._discord_allow_any_attachment() is True + monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "no") + assert adapter._discord_allow_any_attachment() is False + + def test_helper_config_overrides_env(self, adapter, monkeypatch): + """config.yaml setting wins over env var.""" + monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true") + adapter.config.extra["allow_any_attachment"] = False + assert adapter._discord_allow_any_attachment() is False + + def test_max_bytes_helper_invalid_value_falls_back(self, adapter): + """Garbage in max_attachment_bytes config falls back to 32 MiB.""" + adapter.config.extra["max_attachment_bytes"] = "not-a-number" + assert adapter._discord_max_attachment_bytes() == 32 * 1024 * 1024 + diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 4866ac083ac..90aecba4412 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -258,6 +258,8 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels | | `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required | | `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported | +| `DISCORD_ALLOW_ANY_ATTACHMENT` | When `true`, accept attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached and surfaced to the agent as a local path so it can inspect them via `terminal` / `read_file` / `ffprobe`. Default `false`. | +| `DISCORD_MAX_ATTACHMENT_BYTES` | Maximum bytes per attachment the gateway will cache. Default `33554432` (32 MiB). Set to `0` for no cap (attachments are held in memory while being written). | | `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) | | `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds | | `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading | diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 50f1641f093..5cad7a4a535 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -294,6 +294,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_ALLOW_MENTION_USERS` | No | `true` | When `true` (default), the bot can ping individual users by ID. | | `DISCORD_ALLOW_MENTION_REPLIED_USER` | No | `true` | When `true` (default), replying to a message pings the original author. | | `DISCORD_PROXY` | No | — | Proxy URL for Discord connections (HTTP, WebSocket, REST). Overrides `HTTPS_PROXY`/`ALL_PROXY`. Supports `http://`, `https://`, and `socks5://` schemes. | +| `DISCORD_ALLOW_ANY_ATTACHMENT` | No | `false` | When `true`, the bot accepts attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached to disk and surfaced to the agent as a local path with `application/octet-stream` MIME so it can inspect them with `terminal` / `read_file` / `ffprobe` / etc. | +| `DISCORD_MAX_ATTACHMENT_BYTES` | No | `33554432` | Maximum bytes per attachment the gateway will download and cache. Default 32 MiB. Set to `0` for no cap (attachments are held in memory while being written, so unlimited carries a real memory cost). | | `HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS` | No | `0.6` | Grace window the adapter waits before flushing a queued text chunk. Useful for smoothing streamed output. | | `HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS` | No | `2.0` | Delay between split chunks when a single message exceeds Discord's length limit. | @@ -613,6 +615,25 @@ The Discord adapter supports native file uploads for every common media type via Discord's per-upload size limit depends on the server's boost tier (25 MB free, up to 500 MB). If Hermes gets an HTTP 413, the adapter falls back to a link pointing at the local cache path rather than failing silently. +## Receiving Arbitrary File Types + +By default the bot caches uploads that match a built-in allowlist — images, audio, video, PDF, text/markdown/csv/log, JSON/XML/YAML/TOML, zip, docx/xlsx/pptx. Anything else (a `.wav`, a `.bin`, a custom-extension dump) gets logged as `Unsupported document type` and dropped before the agent sees it. + +To accept arbitrary file types, enable `discord.allow_any_attachment`: + +```yaml +discord: + allow_any_attachment: true + # Optional — raise/disable the per-file size cap. Default is 32 MiB. + # The whole file is held in memory while being cached, so unlimited + # uploads carry a real memory cost. + max_attachment_bytes: 33554432 # bytes; 0 = unlimited +``` + +When the flag is on, any uploaded file is downloaded, cached under `~/.hermes/cache/documents/`, and surfaced to the agent as a `DOCUMENT`-typed message event with `application/octet-stream` MIME. The agent receives a context note pointing at the local path (auto-translated for Docker/Modal sandboxed terminals via `to_agent_visible_cache_path`) and can inspect the file with `terminal` (`ffprobe`, `unzip`, `file`, `strings`, etc.) or `read_file`. The file body is **not** inlined into the prompt — only the path — so binary uploads don't blow up the context window. + +Known-text formats already in the allowlist (`.txt`, `.md`, `.log`) continue to have their contents auto-injected up to 100 KiB; that behavior is unchanged when the flag is on. + ## Home Channel You can designate a "home channel" where the bot sends proactive messages (such as cron job output, reminders, and notifications). There are two ways to set it: From a1e3d7969e4b448bb56073872f00e013098206a4 Mon Sep 17 00:00:00 2001 From: Saurav0989 Date: Sun, 17 May 2026 00:51:19 +0530 Subject: [PATCH 02/41] docs: add hermes-eval to Community section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index efe5515f4d8..25c84f61f66 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ scripts/run_tests.sh - 📚 [Skills Hub](https://agentskills.io) - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. +- 🛠️ [hermes-eval](https://github.com/Saurav0989/hermes-eval) — Skill regression testing and trajectory quality scoring. Catch skill drift before it propagates. Exports quality-filtered trajectories in Atropos RL format. GitHub Actions CI template included. --- From df80bda77831abc8beec9dfdec0b9e39565b8b68 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sat, 16 May 2026 12:07:09 +0200 Subject: [PATCH 03/41] docs: add Hermes MemPalace to Community plugins section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 25c84f61f66..1b9e8c369c9 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ scripts/run_tests.sh - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. - 🛠️ [hermes-eval](https://github.com/Saurav0989/hermes-eval) — Skill regression testing and trajectory quality scoring. Catch skill drift before it propagates. Exports quality-filtered trajectories in Atropos RL format. GitHub Actions CI template included. +- 🧠 [Hermes MemPalace](https://github.com/kjames2001/hermes-mempalace) — Native MemPalace memory provider plugin: semantic search, knowledge graph, and diary journaling via ChromaDB. --- From d5ce85c423af825bffb5333bfd433a805e29bdeb Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Thu, 14 May 2026 20:27:09 +0300 Subject: [PATCH 04/41] docs: add computer-use-linux community MCP --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1b9e8c369c9..b934293a8df 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ scripts/run_tests.sh - 💬 [Discord](https://discord.gg/NousResearch) - 📚 [Skills Hub](https://agentskills.io) - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) +- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Linux desktop-control MCP server for Hermes and other MCP hosts, with AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting. - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. - 🛠️ [hermes-eval](https://github.com/Saurav0989/hermes-eval) — Skill regression testing and trajectory quality scoring. Catch skill drift before it propagates. Exports quality-filtered trajectories in Atropos RL format. GitHub Actions CI template included. - 🧠 [Hermes MemPalace](https://github.com/kjames2001/hermes-mempalace) — Native MemPalace memory provider plugin: semantic search, knowledge graph, and diary journaling via ChromaDB. From 31a805883b1a39826f5faf13190d37164d3ba2ad Mon Sep 17 00:00:00 2001 From: r266-tech Date: Sun, 17 May 2026 04:18:58 +0800 Subject: [PATCH 05/41] docs(delegation): show api_mode override in custom-endpoint example --- website/docs/user-guide/features/delegation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index ec09d148f94..077e2083d7a 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -274,6 +274,7 @@ delegation: model: "qwen2.5-coder" base_url: "http://localhost:1234/v1" api_key: "local-key" + # api_mode: "anthropic_messages" # Optional. Wire protocol override for base_url ("chat_completions", "codex_responses", or "anthropic_messages"). Empty = auto-detect from URL (e.g. /anthropic suffix). Set explicitly for endpoints the heuristic can't classify (Azure AI Foundry, MiniMax, Zhipu GLM, LiteLLM proxies, …). ``` :::tip From 86f3776a7252126d1eed6651fe1fe6668b7ed85e Mon Sep 17 00:00:00 2001 From: r266-tech Date: Sun, 17 May 2026 04:18:50 +0800 Subject: [PATCH 06/41] docs(delegation): document api_mode wire-protocol override from #26824 --- website/docs/user-guide/configuration.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 77e5d74ad42..5ac0d8c9df2 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1667,6 +1667,7 @@ delegation: # provider: "openrouter" # Override provider (empty = inherit parent) # base_url: "http://localhost:1234/v1" # Direct OpenAI-compatible endpoint (takes precedence over provider) # api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY) + # api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect. max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var. max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels. orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth. @@ -1676,6 +1677,8 @@ delegation: **Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only. +**Wire protocol (`api_mode`):** Hermes auto-detects the wire protocol from `delegation.base_url` (e.g. paths ending in `/anthropic` → `anthropic_messages`; Codex / native Anthropic / Kimi-coding hostnames keep their existing detection). For endpoints the heuristic can't classify — for example Azure AI Foundry, MiniMax, Zhipu GLM, or LiteLLM proxies fronting an Anthropic-shaped backend — set `delegation.api_mode` explicitly to one of `chat_completions`, `codex_responses`, or `anthropic_messages`. Leave it empty (the default) to keep auto-detection. + The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `copilot`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed. **Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter). From 6f7292a555b425005fc80190f9480bad305c7ba4 Mon Sep 17 00:00:00 2001 From: r266-tech Date: Sat, 16 May 2026 00:18:31 +0800 Subject: [PATCH 07/41] docs(cron): document name-based job lookup from #26231 --- website/docs/user-guide/features/cron.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 9a14e6dcd1e..9772d433812 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -125,6 +125,10 @@ Jobs with a `workdir` run sequentially on the scheduler tick, not in the paralle You do not need to delete and recreate jobs just to change them. +:::tip Job reference +The `` placeholder below (and in [Lifecycle actions](#lifecycle-actions)) also accepts the job's name (case-insensitive) — handy when you remember `morning-digest` but not the hex ID. An exact job ID takes precedence over name matches; if the reference is not an ID and a name matches more than one job, the command refuses and prints the candidate IDs so you can disambiguate. +::: + ### Chat ```bash From 49bd95c43203a2264a7352acc47c35c34c8d5a65 Mon Sep 17 00:00:00 2001 From: r266-tech Date: Sat, 16 May 2026 04:12:36 +0800 Subject: [PATCH 08/41] docs(security): document YOLO mode visual indicators added in #26238 --- website/docs/user-guide/security.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 2a48deb2448..0ff53539057 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -64,6 +64,11 @@ The `/yolo` command is a **toggle** — each use flips the mode on or off: YOLO mode is available in both CLI and gateway sessions. Internally, it sets the `HERMES_YOLO_MODE` environment variable which is checked before every command execution. +When YOLO is active, Hermes shows two persistent visual reminders so it's hard to forget that approval prompts are bypassed: + +- A red banner line at session start when YOLO is already active: `⚠ YOLO mode — all approval prompts bypassed`. Hidden when YOLO is off so the default banner stays uncluttered. +- A `⚠ YOLO` fragment in the status bar across all width tiers, updated live as you toggle YOLO on or off (rich-text renderer and plain-text fallback). + :::danger YOLO mode disables **all** dangerous command safety checks for the session — **except** the hardline blocklist (see below). Use only when you fully trust the commands being generated (e.g., well-tested automation scripts in disposable environments). ::: From c741eacd0c1d81ec8db93bfe7e4cf8296d2f3d55 Mon Sep 17 00:00:00 2001 From: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com> Date: Sat, 16 May 2026 20:31:34 -0700 Subject: [PATCH 09/41] docs(spotify): document Home Assistant speaker routing --- website/docs/user-guide/features/spotify.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/features/spotify.md b/website/docs/user-guide/features/spotify.md index 5e57688e48f..e9b8f3748a1 100644 --- a/website/docs/user-guide/features/spotify.md +++ b/website/docs/user-guide/features/spotify.md @@ -12,7 +12,7 @@ Unlike Hermes' built-in OAuth integrations (Google, GitHub Copilot, Codex), Spot ## Setup -### One-shot: `hermes tools` +### One-shot: `hermes tools` or first-run setup The fastest path. Run: @@ -20,7 +20,9 @@ The fastest path. Run: hermes tools ``` -Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass. +Scroll to `🎵 Spotify`, press space to toggle it on, then `s` to save. The same toggle is also available during the first-run `hermes setup` / `hermes setup tools` flow. Spotify stays opt-in, so enabling it there runs the same provider-aware configuration as `hermes tools`. + +Hermes drops you straight into the OAuth flow — if you don't have a Spotify app yet, it walks you through creating one inline. Once you finish, the toolset is enabled AND authenticated in one pass. If you prefer to do the steps separately (or you're re-authing later), use the two-step flow below. @@ -125,6 +127,12 @@ Control and inspect playback, plus fetch recently played history. | `list` | Every Spotify Connect device visible to your account | | `transfer` | Move playback to `device_id`. Optional `play: true` starts playback on transfer | +### Home Assistant-managed speakers + +If Home Assistant manages speakers that already support Spotify Connect (for example Sonos, Echo, Nest, or other Connect-capable speakers), they appear in `spotify_devices list` automatically whenever Spotify can see them. Hermes does not need a Home Assistant ↔ Spotify bridge for this path — Spotify handles the device routing natively. + +Ask Hermes to transfer playback by the speaker's display name (for example, “transfer Spotify to the kitchen speaker”), or call `spotify_devices list` and pass the exact `device_id` to `spotify_devices transfer` when scripting. If the speaker is missing, open the Spotify app or the speaker's Spotify integration once so Spotify registers it as an active Connect target. + #### `spotify_queue` | Action | Purpose | Premium? | |--------|---------|----------| From 903ac23bc879cbeb9d70b1941176a91cc736b643 Mon Sep 17 00:00:00 2001 From: aqilaziz Date: Fri, 15 May 2026 18:44:04 +0700 Subject: [PATCH 10/41] docs(dashboard): clarify chat tab tui flag --- website/docs/reference/cli-commands.md | 9 ++++++++- website/docs/user-guide/features/web-dashboard.md | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 3b5b7d2e925..4cfc80191f1 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1106,13 +1106,17 @@ hermes claw migrate --source /home/user/old-openclaw hermes dashboard [options] ``` -Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation. +Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). The embedded browser Chat tab requires `--tui` plus the `pty` extra. See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation. | Option | Default | Description | |--------|---------|-------------| | `--port` | `9119` | Port to run the web server on | | `--host` | `127.0.0.1` | Bind address | | `--no-open` | — | Don't auto-open the browser | +| `--tui` | off | Enable the in-browser Chat tab by running `hermes --tui` behind a PTY/WebSocket bridge. Requires `pip install 'hermes-agent[web,pty]'` and a POSIX PTY environment such as Linux, macOS, or WSL2. | +| `--insecure` | off | Allow binding to non-localhost hosts. Exposes dashboard credentials on the network; use only behind trusted network controls. | +| `--stop` | — | Stop running `hermes dashboard` processes and exit. | +| `--status` | — | List running `hermes dashboard` processes and exit. | ```bash # Default — opens browser to http://127.0.0.1:9119 @@ -1120,6 +1124,9 @@ hermes dashboard # Custom port, no browser hermes dashboard --port 8080 --no-open + +# Enable the browser Chat tab +hermes dashboard --tui ``` ## `hermes profile` diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index e7968498586..d7201cbbe08 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -35,6 +35,9 @@ hermes dashboard --host 0.0.0.0 # Start without opening browser hermes dashboard --no-open + +# Enable the in-browser Chat tab +hermes dashboard --tui ``` ## Prerequisites @@ -49,6 +52,8 @@ The `web` extra pulls in FastAPI/Uvicorn; `pty` pulls in `ptyprocess` (POSIX) or When you run `hermes dashboard` without the dependencies, it will tell you what to install. If the frontend hasn't been built yet and `npm` is available, it builds automatically on first launch. +The Chat tab is intentionally off for a plain `hermes dashboard` launch. Start the dashboard with `hermes dashboard --tui` or set `HERMES_DASHBOARD_TUI=1` when you want the embedded browser chat pane. + ## Pages ### Status From 21078ebcea6dd870835080fdc76a40284418c921 Mon Sep 17 00:00:00 2001 From: PaTTeeL <9150277+PaTTeeL@users.noreply.github.com> Date: Sat, 16 May 2026 20:31:34 -0700 Subject: [PATCH 11/41] fix(fallback): forward custom_providers to fallback model context-length detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same root cause as the auxiliary compression fix (commit 7becb19): get_model_context_length() is called without custom_providers, so per-model context_length overrides are silently skipped. The fallback activation path (_try_activate_fallback) had the same missing parameter. When the agent switches to a fallback provider, the fallback model would use the models.dev value (e.g. 204800 for NVIDIA NIM minimax-m2.7) instead of the user-configured one in custom_providers (e.g. 196608) — a subtle discrepancy that could cause the fallback model to run with an incorrect context window, leading to truncated messages or failed API requests when the model does not support the detected length. Fix: pass self._custom_providers to get_model_context_length() so the fallback path sees the same per-model overrides as the main model path. --- run_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/run_agent.py b/run_agent.py index 2931c4fa349..e2bfd7ed1e4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9187,6 +9187,7 @@ class AIAgent: self.model, base_url=self.base_url, api_key=self.api_key, provider=self.provider, config_context_length=getattr(self, "_config_context_length", None), + custom_providers=self._custom_providers, ) self.context_compressor.update_model( model=self.model, From 7244116b687f6e5ff5e869c99cdbb1b09c822799 Mon Sep 17 00:00:00 2001 From: Matthew Lai Date: Thu, 14 May 2026 00:43:49 +0100 Subject: [PATCH 12/41] feat(agent): Added gemma 4 to reasoning allowlist --- run_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/run_agent.py b/run_agent.py index e2bfd7ed1e4..9324b1c2901 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10123,6 +10123,7 @@ class AIAgent: "openai/", "x-ai/", "google/gemini-2", + "google/gemma-4", "qwen/qwen3", "tencent/hy3-preview", "xiaomi/", From 96b7f3da45931f2f0e8ce6eb880da24d2c44ac7e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 20:31:55 -0700 Subject: [PATCH 13/41] chore(release): AUTHOR_MAP entries for batch salvage contributors Adds release-note attribution mappings for: - @Saurav0989 (PR #27071) - @avifenesh (PR #25902) - @BROCCOLO1D (PR #26796) - @matthewlai (PR #25293) --- scripts/release.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 5d4cb3eb82f..ee4c948f643 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1086,6 +1086,14 @@ AUTHOR_MAP = { "nightcityblade@gmail.com": "nightcityblade", # PR #24138 (docs voice/tts table) "pol.kuijken@gmail.com": "polkn", # PR #6136 salvage (skill_view collision refusal) "robin@soal.org": "rewbs", + # batch salvage (May 2026 LHF run) + "sauravsejal40@gmail.com": "Saurav0989", # PR #27071 (docs: hermes-eval community link) + "220110965+Saurav0989@users.noreply.github.com": "Saurav0989", + "aviarchi1994@gmail.com": "avifenesh", # PR #25902 (docs: computer-use-linux MCP) + "55848801+avifenesh@users.noreply.github.com": "avifenesh", + "279959838+BROCCOLO1D@users.noreply.github.com": "BROCCOLO1D", # PR #26796 (docs: spotify + HA) + "m@matthewlai.ca": "matthewlai", # PR #25293 (feat: gemma 4 reasoning allowlist) + "4296245+matthewlai@users.noreply.github.com": "matthewlai", } From 973f27e95631aaecbda5e32e3fa9e5d7f6a2e1d3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 20:33:38 -0700 Subject: [PATCH 14/41] fix(run_agent): isolate background review fork from external memory plugins (#27190) Pass skip_memory=True to the AIAgent constructor used by _spawn_background_review() so the review fork's __init__ no longer rebuilds a _memory_manager wired to honcho / mem0 / supermemory / etc. under the parent's session_id. Before this change, the review fork ingested its harness prompt (the 'Review the conversation above and update the skill library...' text) into the user's real memory namespace via three sites in run_conversation(): - on_turn_start(turn_count, prompt) cadence + turn-message - prefetch_all(prompt) recall query - sync_all(prompt, review_output, ...) harness + review output recorded as a (user, assistant) pair Built-in MEMORY.md / USER.md state is still rebound from the parent right after construction, so memory(action='add') writes from the review continue to land on disk; only the external-plugin side effects are removed. Reported by @Utku. --- run_agent.py | 16 ++++++++ tests/run_agent/test_background_review.py | 48 +++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/run_agent.py b/run_agent.py index 9324b1c2901..b10a68cf9d0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4386,6 +4386,21 @@ class AIAgent: # owns the loop and the agent-loop tools dispatch. if _parent_api_mode == "codex_app_server": _parent_api_mode = "codex_responses" + # skip_memory=True keeps the review fork from + # touching external memory plugins (honcho, mem0, + # supermemory, etc.). Without it, the fork's + # __init__ rebuilds its own _memory_manager from + # config, scoped to the parent's session_id, and + # run_conversation() then leaks the harness prompt + # into the user's real memory namespace via three + # ingestion sites: on_turn_start (cadence + turn + # message), prefetch_all (recall query), and + # sync_all (harness prompt + review output recorded + # as a (user, assistant) turn pair). Built-in + # MEMORY.md / USER.md state is re-bound from the + # parent below so memory(action="add") writes from + # the review still land on disk; the review just + # has zero side effects on external providers. review_agent = AIAgent( model=self.model, max_iterations=16, @@ -4397,6 +4412,7 @@ class AIAgent: api_key=_parent_runtime.get("api_key") or None, credential_pool=getattr(self, "_credential_pool", None), parent_session_id=self.session_id, + skip_memory=True, ) review_agent._memory_write_origin = "background_review" review_agent._memory_write_context = "background_review" diff --git a/tests/run_agent/test_background_review.py b/tests/run_agent/test_background_review.py index 2e79b10b346..89626f857d5 100644 --- a/tests/run_agent/test_background_review.py +++ b/tests/run_agent/test_background_review.py @@ -193,3 +193,51 @@ def test_background_review_summary_is_attributed_to_self_improvement_loop(monkey assert captured_bg_callback[0].startswith("💾 Self-improvement review:"), ( captured_bg_callback[0] ) + + +def test_background_review_fork_skips_external_memory_plugins(monkeypatch): + """The background review fork must NOT touch external memory plugins. + + Without skip_memory=True on the fork constructor, AIAgent.__init__ + rebuilds its own _memory_manager from config, scoped to the parent's + session_id. The review fork's run_conversation() then leaks the + harness prompt into the user's real memory namespace via three + ingestion sites: on_turn_start (cadence + turn message), + prefetch_all (recall query), and sync_all (harness prompt + review + output recorded as a (user, assistant) turn pair). The fix is a + single kwarg on the fork constructor — this test guards it. + """ + captured_kwargs: dict = {} + + class FakeReviewAgent: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + self._session_messages = [] + + def run_conversation(self, **kwargs): + pass + + def shutdown_memory_provider(self): + pass + + def close(self): + pass + + monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent) + monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread) + + agent = _bare_agent() + + AIAgent._spawn_background_review( + agent, + messages_snapshot=[{"role": "user", "content": "hello"}], + review_memory=True, + ) + + assert captured_kwargs.get("skip_memory") is True, ( + "Background review fork must be constructed with skip_memory=True " + "so AIAgent.__init__ does not rebuild a _memory_manager wired to " + "external plugins (honcho, mem0, supermemory, ...). Without this " + "the fork leaks harness prompts into the user's real memory " + "namespace via on_turn_start / prefetch_all / sync_all." + ) From 290bf93104652bf6acaf50151f0ddac54cb69fde Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 22:51:51 -0500 Subject: [PATCH 15/41] fix(tui): harden Terminal.app render behavior Avoid Terminal.app paint corruption by disabling fast-echo in that terminal, sanitizing non-SGR control sequences before ANSI rendering, and defaulting Apple Terminal back to the safer 256-color path unless truecolor is explicitly requested. --- hermes_cli/main.py | 24 ++++++- tests/hermes_cli/test_tui_resume_flow.py | 28 ++++++++ ui-tui/src/__tests__/forceTruecolor.test.ts | 68 +++++++++++++++++++ ui-tui/src/__tests__/text.test.ts | 24 +++++++ .../src/__tests__/textInputFastEcho.test.ts | 13 +++- ui-tui/src/components/messageLine.tsx | 8 ++- ui-tui/src/components/textInput.tsx | 8 ++- ui-tui/src/lib/forceTruecolor.ts | 30 ++++++++ ui-tui/src/lib/text.ts | 21 +++++- 9 files changed, 214 insertions(+), 10 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index bd8fe6c5cff..662bc57b78d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1080,7 +1080,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [node, str(bundled)], bundled.parent # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. - # --dev flow: npm install if needed, then tsx src/entry.tsx (no build). + # --dev flow: npm install if needed, then tsx src/entry.tsx. if _tui_need_npm_install(tui_dir): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): @@ -1102,10 +1102,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: sys.exit(1) if tui_dev: + # Keep the local @hermes/ink package exports in sync with source. + # --dev runs src/entry.tsx directly, but @hermes/ink resolves through + # packages/hermes-ink/dist/entry-exports.js. If that dist bundle is + # stale after a pull, newer hooks/components can exist in src while + # being missing at runtime (e.g. useCursorAdvance). Prebuild it here. + npm = _node_bin("npm") + ink_dir = tui_dir / "packages" / "hermes-ink" + result = subprocess.run( + [npm, "run", "build"], + cwd=str(ink_dir), + capture_output=True, + text=True, + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("TUI dev prebuild failed.") + if preview: + print(preview) + sys.exit(1) + tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): return [str(tsx), "src/entry.tsx"], tui_dir - npm = _node_bin("npm") return [npm, "start"], tui_dir # Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs. diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index fe6f0358069..25e478ccd2c 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -523,6 +523,34 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): assert env["NODE_ENV"] == "production" +def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path): + tui_dir = tmp_path / "ui-tui" + tsx = tui_dir / "node_modules" / ".bin" / "tsx" + ink_dir = tui_dir / "packages" / "hermes-ink" + tsx.parent.mkdir(parents=True) + ink_dir.mkdir(parents=True) + tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8") + + monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) + monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False) + monkeypatch.delenv("HERMES_TUI_DIR", raising=False) + monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}") + + calls = [] + + def fake_run(cmd, cwd=None, **_kwargs): + calls.append((cmd, cwd)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(main_mod.subprocess, "run", fake_run) + + argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True) + + assert argv == [str(tsx), "src/entry.tsx"] + assert cwd == tui_dir + assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))] + + def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): import hermes_cli.main as main_mod diff --git a/ui-tui/src/__tests__/forceTruecolor.test.ts b/ui-tui/src/__tests__/forceTruecolor.test.ts index 4d978328152..03d30fa69b7 100644 --- a/ui-tui/src/__tests__/forceTruecolor.test.ts +++ b/ui-tui/src/__tests__/forceTruecolor.test.ts @@ -52,6 +52,50 @@ describe('forceTruecolor', () => { ) }) + it('downgrades Apple Terminal when truecolor is only advertised by env', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.COLORTERM = 'truecolor' + process.env.FORCE_COLOR = '3' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++) + expect( + mod.shouldDowngradeAppleTerminalTruecolor({ + TERM_PROGRAM: 'Apple_Terminal', + COLORTERM: 'truecolor', + FORCE_COLOR: '3' + } as NodeJS.ProcessEnv) + ).toBe(true) + expect(process.env.COLORTERM).toBeUndefined() + expect(process.env.FORCE_COLOR).toBeUndefined() + } + ) + }) + + it('keeps non-Apple terminals untouched when they advertise truecolor', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'vscode' + process.env.COLORTERM = 'truecolor' + process.env.FORCE_COLOR = '3' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++) + expect( + mod.shouldDowngradeAppleTerminalTruecolor({ + TERM_PROGRAM: 'vscode', + COLORTERM: 'truecolor', + FORCE_COLOR: '3' + } as NodeJS.ProcessEnv) + ).toBe(false) + expect(process.env.COLORTERM).toBe('truecolor') + expect(process.env.FORCE_COLOR).toBe('3') + } + ) + }) + it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => { await withCleanEnv( () => { @@ -79,6 +123,30 @@ describe('forceTruecolor', () => { ) }) + it('lets explicit opt-in keep Apple truecolor advertisement', async () => { + await withCleanEnv( + () => { + process.env.TERM_PROGRAM = 'Apple_Terminal' + process.env.COLORTERM = 'truecolor' + process.env.FORCE_COLOR = '3' + process.env.HERMES_TUI_TRUECOLOR = '1' + }, + async () => { + const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++) + expect( + mod.shouldDowngradeAppleTerminalTruecolor({ + TERM_PROGRAM: 'Apple_Terminal', + COLORTERM: 'truecolor', + FORCE_COLOR: '3', + HERMES_TUI_TRUECOLOR: '1' + } as NodeJS.ProcessEnv) + ).toBe(false) + expect(process.env.COLORTERM).toBe('truecolor') + expect(process.env.FORCE_COLOR).toBe('3') + } + ) + }) + it('respects NO_COLOR', async () => { await withCleanEnv( () => { diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 92afd1513df..ffd8f849da2 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -8,12 +8,15 @@ import { estimateRows, estimateTokensRough, fmtK, + hasAnsi, isToolTrailResultLine, lastCotTrailIndex, parseToolTrailResultLine, pasteTokenLabel, + sanitizeAnsiForRender, sameToolTrailGroup, splitToolDuration, + stripAnsi, thinkingPreview } from '../lib/text.js' @@ -84,6 +87,27 @@ describe('estimateTokensRough', () => { }) }) +describe('ANSI sanitizers', () => { + const ESC = String.fromCharCode(27) + const BEL = String.fromCharCode(7) + + it('strips CSI/OSC/control bytes from plain previews', () => { + const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD` + + expect(stripAnsi(sample)).toBe('ABCD') + }) + + it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => { + const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC` + + expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`) + }) + + it('detects non-CSI escape prefixes too', () => { + expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true) + }) +}) + describe('thinkingPreview', () => { it('adds paragraph breaks before markdown thinking headings', () => { const raw = diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index 2e08111ffb4..83b5c511940 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js' +import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js' // The fast-echo path bypasses Ink and writes characters directly to stdout // for the common case of typing plain English at the end of the line. These @@ -172,3 +172,14 @@ describe('canFastBackspaceShape', () => { expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true) }) }) + +describe('supportsFastEchoTerminal', () => { + it('disables fast-echo in Apple Terminal', () => { + expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false) + }) + + it('keeps fast-echo enabled in VS Code and unknown terminals', () => { + expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true) + expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) + }) +}) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 238b551ae97..f44f1813804 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -12,6 +12,7 @@ import { compactPreview, hasAnsi, isPasteBackedText, + sanitizeAnsiForRender, stripAnsi } from '../lib/text.js' import type { Theme } from '../theme.js' @@ -85,13 +86,14 @@ export const MessageLine = memo(function MessageLine({ if (msg.role === 'tool') { const maxChars = Math.max(24, cols - 14) const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text + const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text const preview = compactPreview(stripped, maxChars) || '(empty tool result)' return ( {hasAnsi(msg.text) ? ( - {msg.text} + {safeAnsi} ) : ( @@ -129,13 +131,13 @@ export const MessageLine = memo(function MessageLine({ {msg.text.length.toLocaleString()} chars - {systemOpen && {msg.text}} + {systemOpen && {sanitizeAnsiForRender(msg.text)}} ) } if (msg.role !== 'user' && hasAnsi(msg.text)) { - return {msg.text} + return {sanitizeAnsiForRender(msg.text)} } if (msg.role === 'assistant') { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index b3c79357368..ace2f479dc1 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -283,6 +283,12 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?: return ASCII_PRINTABLE_RE.test(removed) } +export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + // Terminal.app still shows paint/cursor artifacts under the fast-echo + // bypass path. Fall back to the normal Ink render path there. + return (env.TERM_PROGRAM ?? '').trim() !== 'Apple_Terminal' +} + function renderWithCursor(value: string, cursor: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -559,7 +565,7 @@ export function TextInput({ }, 16) } - const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY + const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY const canFastAppend = (current: string, cursor: number, text: string) => canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current) diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts index 25de7b2dc34..cd63154e040 100644 --- a/ui-tui/src/lib/forceTruecolor.ts +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -19,12 +19,42 @@ export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): bool return TRUE_RE.test(override) } +const isAppleTerminal = (env: NodeJS.ProcessEnv = process.env) => (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' + +const isAdvertisedTruecolor = (env: NodeJS.ProcessEnv = process.env) => { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const forceColor = (env.FORCE_COLOR ?? '').trim() + + return colorTerm === 'truecolor' || colorTerm === '24bit' || forceColor === '3' +} + +export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = process.env): boolean { + if (!isAppleTerminal(env)) { + return false + } + + if (shouldForceTruecolor(env)) { + return false + } + + return isAdvertisedTruecolor(env) +} + if (shouldForceTruecolor()) { if (!process.env.COLORTERM) { process.env.COLORTERM = 'truecolor' } process.env.FORCE_COLOR = '3' +} else if (shouldDowngradeAppleTerminalTruecolor()) { + // Terminal.app may advertise truecolor even when RGB SGR paths render + // incorrectly. Keep Hermes on the safer TERM-driven 256-color path unless + // users explicitly opt back in via HERMES_TUI_TRUECOLOR=1. + delete process.env.COLORTERM + + if ((process.env.FORCE_COLOR ?? '').trim() === '3') { + delete process.env.FORCE_COLOR + } } export {} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 744046f6be4..46dd1f67e15 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -9,12 +9,27 @@ import { VERBS } from '../content/verbs.js' import type { ThinkingMode } from '../types.js' const ESC = String.fromCharCode(27) -const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g') +const BEL = String.fromCharCode(7) +const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g') +const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g') +const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g') +const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g const WS_RE = /\s+/g -export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') +export const stripAnsi = (s: string) => + s.replace(ANSI_OSC_RE, '').replace(ANSI_STRING_RE, '').replace(ANSI_CSI_RE, '').replace(ANSI_STRAY_ESC_RE, '').replace(CONTROL_RE, '') -export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`) +export const sanitizeAnsiForRender = (s: string) => + s + .replace(ANSI_OSC_RE, '') + .replace(ANSI_STRING_RE, '') + .replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : '')) + .replace(ANSI_STRAY_ESC_RE, '') + .replace(CONTROL_RE, '') + +export const hasAnsi = (s: string) => s.includes(ESC) const renderEstimateLine = (line: string) => { const trimmed = line.trim() From 9b2d58159c70b46214d0ef961168bbc826651663 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 22:55:42 -0500 Subject: [PATCH 16/41] fix(cli): satisfy ruff encoding requirement in send_cmd Specify utf-8 when reading message bodies from --file paths so the full-repo ruff enforcement check passes in CI. --- hermes_cli/send_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 451bb3b4964..2d0c3418ea2 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -58,7 +58,7 @@ def _read_message_body( if file_path == "-": return sys.stdin.read() try: - return Path(file_path).read_text() + return Path(file_path).read_text(encoding="utf-8") except OSError as exc: print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) sys.exit(_USAGE_EXIT) From 7e1788db5d569f61d3aed32f74963208b03835ec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 22:58:00 -0500 Subject: [PATCH 17/41] fix(tui): harden ansi sanitizers for dangling CSI Strip incomplete CSI prefixes before rendering, remove carriage returns from sanitized output, and add regression tests to prevent escape-sequence recomposition across message boundaries. --- ui-tui/src/__tests__/text.test.ts | 12 ++++++++++++ ui-tui/src/lib/text.ts | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index ffd8f849da2..566d1e41cf6 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -97,12 +97,24 @@ describe('ANSI sanitizers', () => { expect(stripAnsi(sample)).toBe('ABCD') }) + it('strips incomplete CSI prefixes and carriage returns', () => { + const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE` + + expect(stripAnsi(sample)).toBe('ABDE') + }) + it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => { const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC` expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`) }) + it('keeps valid SGR while removing dangling CSI and carriage returns', () => { + const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD` + + expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`) + }) + it('detects non-CSI escape prefixes too', () => { expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true) }) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 46dd1f67e15..5a5bdce603d 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -12,20 +12,30 @@ const ESC = String.fromCharCode(27) const BEL = String.fromCharCode(7) const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g') const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g') +const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g') const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g') -const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g +const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g const WS_RE = /\s+/g export const stripAnsi = (s: string) => - s.replace(ANSI_OSC_RE, '').replace(ANSI_STRING_RE, '').replace(ANSI_CSI_RE, '').replace(ANSI_STRAY_ESC_RE, '').replace(CONTROL_RE, '') + s + .replace(ANSI_OSC_RE, '') + .replace(ANSI_STRING_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_CSI_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_STRAY_ESC_RE, '') + .replace(CONTROL_RE, '') export const sanitizeAnsiForRender = (s: string) => s .replace(ANSI_OSC_RE, '') .replace(ANSI_STRING_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') .replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : '')) + .replace(ANSI_INCOMPLETE_CSI_RE, '') .replace(ANSI_STRAY_ESC_RE, '') .replace(CONTROL_RE, '') From a65f723e6847f8d326947011b0d6d345d240ce25 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 23:00:58 -0500 Subject: [PATCH 18/41] fix(review): address Copilot follow-up on sanitizer and file decode errors Consume multi-byte non-CSI ESC sequences during ANSI sanitization and handle UnicodeDecodeError for `hermes send --file` so review findings are resolved without regressions. --- hermes_cli/send_cmd.py | 2 +- tests/hermes_cli/test_send_cmd.py | 13 +++++++++++++ ui-tui/src/__tests__/text.test.ts | 7 +++++++ ui-tui/src/lib/text.ts | 3 +++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 2d0c3418ea2..4cf3198cb40 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -59,7 +59,7 @@ def _read_message_body( return sys.stdin.read() try: return Path(file_path).read_text(encoding="utf-8") - except OSError as exc: + except (OSError, UnicodeDecodeError) as exc: print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) sys.exit(_USAGE_EXIT) diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index 9202315e3d4..802cff88c90 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -173,6 +173,19 @@ def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch): assert "cannot read" in err.lower() +def test_file_decode_error_is_usage_error(fake_tool, capsys, monkeypatch, tmp_path): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + bad = tmp_path / "bad-bytes.bin" + bad.write_bytes(b"\xff\xfe\x00") + + args = _parse(["--to", "telegram", "--file", str(bad)]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "cannot read" in err.lower() + + def test_tool_error_returns_failure_exit(monkeypatch, capsys): import sys as _sys import types as _types diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 566d1e41cf6..047ad67912f 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -115,6 +115,13 @@ describe('ANSI sanitizers', () => { expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`) }) + it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => { + const sample = `A${ESC}(0B${ESC}%GC${ESC})0D` + + expect(stripAnsi(sample)).toBe('ABCD') + expect(sanitizeAnsiForRender(sample)).toBe('ABCD') + }) + it('detects non-CSI escape prefixes too', () => { expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true) }) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 5a5bdce603d..ef3a1816975 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -15,6 +15,7 @@ const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g') const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g') const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g') const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g') const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g const WS_RE = /\s+/g @@ -26,6 +27,7 @@ export const stripAnsi = (s: string) => .replace(ANSI_INCOMPLETE_CSI_RE, '') .replace(ANSI_CSI_RE, '') .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') .replace(ANSI_STRAY_ESC_RE, '') .replace(CONTROL_RE, '') @@ -36,6 +38,7 @@ export const sanitizeAnsiForRender = (s: string) => .replace(ANSI_INCOMPLETE_CSI_RE, '') .replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : '')) .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') .replace(ANSI_STRAY_ESC_RE, '') .replace(CONTROL_RE, '') From 32c3f06a5bf0867d11d309503122f59d0dba75d9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 22:03:37 -0700 Subject: [PATCH 19/41] docs(readme): remove hermes-eval and Hermes MemPalace from Community links (#27271) Both links were merged from low-risk batch salvage but on review they're brand-new single-commit personal repos with zero stars/forks and no track record. README links from us implicitly endorse community projects; the Community section should have a minimum activity bar before we link to a repo, not just "the contributor opened a PR." MemPalace in particular wraps an in-process memory provider, so a README endorsement carries more risk than a typical docs link. --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index b934293a8df..abdc66245f3 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,6 @@ scripts/run_tests.sh - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) - 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Linux desktop-control MCP server for Hermes and other MCP hosts, with AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting. - 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. -- 🛠️ [hermes-eval](https://github.com/Saurav0989/hermes-eval) — Skill regression testing and trajectory quality scoring. Catch skill drift before it propagates. Exports quality-filtered trajectories in Atropos RL format. GitHub Actions CI template included. -- 🧠 [Hermes MemPalace](https://github.com/kjames2001/hermes-mempalace) — Native MemPalace memory provider plugin: semantic search, knowledge graph, and diary journaling via ChromaDB. --- From 4e9cedcd4c6de05a0603a3969b6991fb0836761e Mon Sep 17 00:00:00 2001 From: 0xchainer <109617724+0xchainer@users.noreply.github.com> Date: Sun, 17 May 2026 01:19:36 +0300 Subject: [PATCH 20/41] fix(gateway): add missing logger definition to prevent NameError in _all_platforms hermes_cli/gateway.py:3702 referenced logger.debug() but 'logger' was never defined in the module, causing a NameError at runtime if the try/except around discover_plugins() caught an exception. Added import logging and logger = logging.getLogger(__name__) at module level to resolve the undefined name. --- hermes_cli/gateway.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a865bcaf8be..c5303e32799 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -5,6 +5,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup] """ import asyncio +import logging import os import shutil import signal @@ -38,6 +39,7 @@ from hermes_cli.setup import ( ) from hermes_cli.colors import Colors, color +logger = logging.getLogger(__name__) # ============================================================================= # Process Management (for manual gateway runs) From 57feef320178ce79070dc7ac9a399d3cf587eca4 Mon Sep 17 00:00:00 2001 From: 0xchainer <109617724+0xchainer@users.noreply.github.com> Date: Sun, 17 May 2026 02:14:22 +0300 Subject: [PATCH 21/41] test(gateway): add smoke test for logger init (regression guard for #27154) Verify that the module has a logger instance with the correct name, preventing regression of the NameError fixed in a31d5aff. --- tests/hermes_cli/test_gateway.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 225947994d2..20c2ca7cda4 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -559,3 +559,9 @@ class TestStopProfileGateway: assert calls["kill"] == 1 # one SIGTERM assert calls["alive_probes"] == 20 # 20 liveness polls over the 2s window assert calls["remove"] == 0 + + +def test_module_has_logger(): + """Verify module has a logger instance (regression guard for #27154).""" + assert hasattr(gateway, "logger") + assert gateway.logger.name == "hermes_cli.gateway" From a81cfd0a0a6de1c3028b9c800ae917d9bd7e5162 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 20:27:45 -0700 Subject: [PATCH 22/41] chore(release): map 0xchainer and kronexoi emails for upcoming salvages --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index ee4c948f643..07952b63c0e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1094,6 +1094,8 @@ AUTHOR_MAP = { "279959838+BROCCOLO1D@users.noreply.github.com": "BROCCOLO1D", # PR #26796 (docs: spotify + HA) "m@matthewlai.ca": "matthewlai", # PR #25293 (feat: gemma 4 reasoning allowlist) "4296245+matthewlai@users.noreply.github.com": "matthewlai", + "109617724+0xchainer@users.noreply.github.com": "0xchainer", # PR #27154/27138/27147 salvage + "201800237+kronexoi@users.noreply.github.com": "kronexoi", # PR #27167 salvage (Teams port fallback) } From 60531889d56a9a9d2b3f0b9ee04aea4145ede392 Mon Sep 17 00:00:00 2001 From: 0xchainer <109617724+0xchainer@users.noreply.github.com> Date: Sun, 17 May 2026 00:35:05 +0300 Subject: [PATCH 23/41] fix: remove unused import and hoist module-level constant - Remove unused from tools/tts_tool.py (dead code) - Move _BUILTIN_DELIVER_PLATFORMS set from send() method to module scope in gateway/platforms/webhook.py to avoid reallocation on every call --- gateway/platforms/webhook.py | 13 +++++++------ tools/tts_tool.py | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 83aa93e94cb..d7714ff5652 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -54,6 +54,13 @@ from gateway.platforms.base import ( logger = logging.getLogger(__name__) +_BUILTIN_DELIVER_PLATFORMS = { + "telegram", "discord", "slack", "signal", "sms", "whatsapp", + "matrix", "mattermost", "homeassistant", "email", "dingtalk", + "feishu", "wecom", "wecom_callback", "weixin", "bluebubbles", + "qqbot", "yuanbao", +} + DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8644 _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" @@ -238,12 +245,6 @@ class WebhookAdapter(BasePlatformAdapter): # Cross-platform delivery — any platform with a gateway adapter. # Check both built-in names and plugin-registered platforms. - _BUILTIN_DELIVER_PLATFORMS = { - "telegram", "discord", "slack", "signal", "sms", "whatsapp", - "matrix", "mattermost", "homeassistant", "email", "dingtalk", - "feishu", "wecom", "wecom_callback", "weixin", "bluebubbles", - "qqbot", "yuanbao", - } _is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS if not _is_known_platform: try: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 57907f76833..9e46fa6a7ef 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -44,7 +44,6 @@ import queue import re import shlex import shutil -import signal import subprocess import tempfile import threading From 4b17c2411ab2518fabe9d87872a03a47d3b8cfcc Mon Sep 17 00:00:00 2001 From: 0xchainer <109617724+0xchainer@users.noreply.github.com> Date: Sun, 17 May 2026 01:08:53 +0300 Subject: [PATCH 24/41] fix(skills): return None instead of truthy stub when skill load fails build_skill_invocation_message() returns a non-empty placeholder string ('[Failed to load skill: ...]') when the skill exists in the command cache but loading the actual SKILL.md payload fails. CLI/gateway callers treat any truthy return value as success, so the failure is silently routed into the model as if it were a valid skill prompt. Return None instead, matching the existing behavior for unknown commands, so callers using 'if msg:' can properly detect the failure. --- agent/skill_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index c8b7d039c46..42e7c857434 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -425,7 +425,7 @@ def build_skill_invocation_message( loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id) if not loaded: - return f"[Failed to load skill: {skill_info['name']}]" + return None loaded_skill, skill_dir, skill_name = loaded From 782d743730e3df193c5969bc9897350fa14429cb Mon Sep 17 00:00:00 2001 From: 0xchainer <109617724+0xchainer@users.noreply.github.com> Date: Sun, 17 May 2026 01:57:34 +0300 Subject: [PATCH 25/41] test(skills): add regression test for skill load failure returning None Add test_returns_none_when_skill_load_fails to verify that build_skill_invocation_message() returns None when a registered skill exists in the command cache but _load_skill_payload() fails. This guards against regression of the fix in 877d01b. --- tests/agent/test_skill_commands.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index bbecd5c43f6..c11976ef978 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -466,6 +466,14 @@ Generate some audio. msg = build_skill_invocation_message("/nonexistent") assert msg is None + def test_returns_none_when_skill_load_fails(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "broken-skill") + scan_skill_commands() + with patch("agent.skill_commands._load_skill_payload", return_value=None): + msg = build_skill_invocation_message("/broken-skill", "do stuff") + assert msg is None + def test_uses_shared_skill_loader_for_secure_setup(self, tmp_path, monkeypatch): monkeypatch.delenv("TENOR_API_KEY", raising=False) calls = [] From 1eadb069c794ecf0626ff5c785191e604ad1f6bc Mon Sep 17 00:00:00 2001 From: shellybotmoyer <258858106+shellybotmoyer@users.noreply.github.com> Date: Sat, 16 May 2026 22:51:17 -0700 Subject: [PATCH 26/41] fix(kanban): --severity filter uses >= comparison per documented behavior (#26379) --- hermes_cli/kanban.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 76f95db4fac..b4024e2e70e 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -1403,7 +1403,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: sev = getattr(args, "severity", None) if sev: for tid in list(diags_by_task.keys()): - kept = [d for d in diags_by_task[tid] if d.severity == sev] + kept = [d for d in diags_by_task[tid] if kd.SEVERITY_ORDER.index(d.severity) >= kd.SEVERITY_ORDER.index(sev)] if kept: diags_by_task[tid] = kept else: From 8d756a421071d0dfd507226c03430413b6de9491 Mon Sep 17 00:00:00 2001 From: austrian_guy <33156212+ether-btc@users.noreply.github.com> Date: Sat, 16 May 2026 00:56:33 +0200 Subject: [PATCH 27/41] fix(run_agent): guard memory provider init against empty/whitespace string --- run_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index b10a68cf9d0..41af78f16c3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2061,7 +2061,7 @@ class AIAgent: try: _mem_provider_name = mem_config.get("provider", "") if mem_config else "" - if _mem_provider_name: + if _mem_provider_name and _mem_provider_name.strip(): from agent.memory_manager import MemoryManager as _MemoryManager from plugins.memory import load_memory_provider as _load_mem self._memory_manager = _MemoryManager() From 7282ef1b9d4ba9b77057f53ca92cbb2ed674129b Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Fri, 15 May 2026 19:39:29 +0100 Subject: [PATCH 28/41] fix: add paste collapse logging to aid debugging Adds logger.info when large pastes are collapsed to file references in both paste-code paths (handle_paste and _on_text_changed). Logs paste ID, line count, character count, and file path so operators can correlate missing- content reports with specific paste files. This is a diagnostic aid, not a fix for the paste-drop issue. --- cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli.py b/cli.py index c1ba1c0ddd2..42b1482578e 100644 --- a/cli.py +++ b/cli.py @@ -12604,6 +12604,7 @@ class HermesCLI: paste_dir.mkdir(parents=True, exist_ok=True) paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" paste_file.write_text(pasted_text, encoding="utf-8") + logger.info("Collapsed paste #%d: %d lines, %d chars -> %s", _paste_counter[0], line_count + 1, len(pasted_text), paste_file) placeholder = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]" prefix = "" if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n': @@ -12771,6 +12772,7 @@ class HermesCLI: paste_dir.mkdir(parents=True, exist_ok=True) paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt" paste_file.write_text(text, encoding="utf-8") + logger.info("Collapsed paste #%d: %d lines, %d chars -> %s (fallback)", _paste_counter[0], line_count + 1, len(text), paste_file) _paste_just_collapsed[0] = True buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]" buf.cursor_position = len(buf.text) From 4279da4db62a8a3cba7004c6fb1e1e53ea2c6a29 Mon Sep 17 00:00:00 2001 From: nekwo Date: Fri, 15 May 2026 12:41:10 -0400 Subject: [PATCH 29/41] fix(windows): make PowerShell installer parse in 5.1 --- scripts/install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5ed7aa755fd..53a1ea96486 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,4 +1,4 @@ -# ============================================================================ +# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). From 7d09bb19155275a18b3952a1f5f399d1dd87df24 Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Fri, 15 May 2026 15:01:32 +0100 Subject: [PATCH 30/41] fix(delegate): tool_trace false-positive error detection for short outputs --- tools/delegate_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 136ea63ac40..e9ad32e0d3a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1649,7 +1649,7 @@ def _run_single_child( trace_by_id[tc_id] = entry_t elif msg.get("role") == "tool": content = msg.get("content", "") - is_error = bool(content and "error" in content[:80].lower()) + is_error = _looks_like_error_output(content) result_meta = { "result_bytes": len(content), "status": "error" if is_error else "ok", From 508b022acb85b5b1395945e1b5f27e4706a81853 Mon Sep 17 00:00:00 2001 From: dgians <188585318+dgians@users.noreply.github.com> Date: Sat, 16 May 2026 22:51:17 -0700 Subject: [PATCH 31/41] feat(gateway): add .ts/.py/.sh to SUPPORTED_DOCUMENT_TYPES The gateway already accepts plain-text config files (.ini, .cfg) and structured formats (.json, .yaml, .toml) as documents, but not common source-file extensions. Sending a .ts/.py/.sh file currently requires renaming it to .txt first. Adds .ts, .py, .sh as text/plain, consistent with the existing .ini/.cfg entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/platforms/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index c6bdc38c3b9..7b3147e21f4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -829,6 +829,9 @@ SUPPORTED_DOCUMENT_TYPES = { ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".ts": "text/plain", + ".py": "text/plain", + ".sh": "text/plain", } From 1a4e64ba06d071551ae8266ea809de0e412b45c8 Mon Sep 17 00:00:00 2001 From: shellybotmoyer <258858106+shellybotmoyer@users.noreply.github.com> Date: Sat, 16 May 2026 22:51:17 -0700 Subject: [PATCH 32/41] fix(credential_pool): parse ISO-string last_status_at during from_dict rehydration (#25516) --- agent/credential_pool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 504742145c1..7f27873a7fb 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -129,6 +129,9 @@ class PooledCredential: def from_dict(cls, provider: str, payload: Dict[str, Any]) -> "PooledCredential": field_names = {f.name for f in fields(cls) if f.name != "provider"} data = {k: payload.get(k) for k in field_names if k in payload} + # Rehydrated last_status_at may be an ISO string from to_dict() — normalize to float epoch + if "last_status_at" in data and isinstance(data["last_status_at"], str): + data["last_status_at"] = _parse_absolute_timestamp(data["last_status_at"]) extra = {k: payload[k] for k in _EXTRA_KEYS if k in payload and payload[k] is not None} data["extra"] = extra data.setdefault("id", uuid.uuid4().hex[:6]) From 5f72dd817ec2709f6fda323ef96dc55e21dd3d0d Mon Sep 17 00:00:00 2001 From: flanny7 Date: Sun, 17 May 2026 02:41:29 +0900 Subject: [PATCH 33/41] fix(install): use resolved python variable in setup_open_webui.sh The install_open_webui function correctly resolved the python interpreter into the $py variable, but hardcoded 'python' in subsequent pip install commands. This caused 'command not found' or 'externally-managed-environment' errors on systems where 'python' is not implicitly aliased to 'python3'. --- scripts/setup_open_webui.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/setup_open_webui.sh b/scripts/setup_open_webui.sh index 0cca44ddd71..9975c911f3f 100755 --- a/scripts/setup_open_webui.sh +++ b/scripts/setup_open_webui.sh @@ -163,8 +163,8 @@ install_open_webui() { "$py" -m venv "$OPEN_WEBUI_VENV" # shellcheck disable=SC1090 source "$OPEN_WEBUI_VENV/bin/activate" - python -m pip install --upgrade pip setuptools wheel - python -m pip install open-webui + "$py" -m pip install --upgrade pip setuptools wheel + "$py" -m pip install open-webui } write_launcher() { From 9a9f8a6d9945c9bf3118c557f85ad1956de4f553 Mon Sep 17 00:00:00 2001 From: hermesagent26 <276067471+hermesagent26@users.noreply.github.com> Date: Sat, 16 May 2026 22:51:17 -0700 Subject: [PATCH 34/41] fix(run_agent): detect kimi models via model name for reasoning pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previously only checked provider ID and base URL. When kimi-k2.6 is served via ollama-cloud (or any third-party provider), provider is not 'kimi-coding' and base URL is not api.kimi.com — so reasoning_content pad was never injected. This caused HTTP 400 from Ollama Cloud's Go backend: 'invalid message content type: map[string]interface {}'. Fix: add model-name detection ('kimi' in model.lower()) so any route serving a kimi model gets the required reasoning_content echo-back. Refs the 400/401 Telegram errors where kimi-k2.6 via ollama-cloud consistently failed after tool-call turns. --- run_agent.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/run_agent.py b/run_agent.py index 41af78f16c3..b239f2aeb60 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10437,12 +10437,16 @@ class AIAgent: Kimi ``/coding`` and Moonshot thinking mode both require ``reasoning_content`` on every assistant tool-call message; omitting it causes the next replay to fail with HTTP 400. + + Also detects Kimi models served through third-party providers (e.g. + ollama-cloud) by matching ``kimi`` in the model name. """ return ( self.provider in {"kimi-coding", "kimi-coding-cn"} or base_url_host_matches(self.base_url, "api.kimi.com") or base_url_host_matches(self.base_url, "moonshot.ai") or base_url_host_matches(self.base_url, "moonshot.cn") + or "kimi" in (self.model or "").lower() ) def _needs_deepseek_tool_reasoning(self) -> bool: From 773a0faca0888df7b1ea310c554b70a18710813c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 22:52:28 -0700 Subject: [PATCH 35/41] fix(deepseek): set default_aux_model on profile so aux warning stops firing Closes #26924 (and supersedes #26926) in spirit. DeepSeek was missing `default_aux_model` on its `ProviderProfile`, so `_get_aux_model_for_provider("deepseek")` returned an empty string and the compression / vision / session-search paths emitted "No auxiliary LLM provider configured -- context compression will drop middle turns without a summary." on every DeepSeek session, even when the user had perfectly working DeepSeek credentials. Fix lands at the profile layer rather than the legacy `_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict the original PR targeted. Every modern provider (gemini, zai, minimax, anthropic, kimi-coding, stepfun, ollama-cloud, gmi, novita, kilocode, ai-gateway, opencode-zen) sets `default_aux_model` on its `ProviderProfile`; the fallback dict only exists for providers that predate the profiles system. Tests added under `tests/plugins/model_providers/test_deepseek_profile.py`: - `test_profile_advertises_deepseek_chat` -- pins the profile attribute - `test_consumer_api_returns_deepseek_chat` -- pins the consumer API behavior - `test_consumer_api_returns_non_empty` -- regression guard for the symptom in the issue Original diagnosis and aux-model choice from @kriscolab in PR #26926; moved one layer up. Co-authored-by: kriscolab <71590782+kriscolab@users.noreply.github.com> --- plugins/model-providers/deepseek/__init__.py | 1 + .../model_providers/test_deepseek_profile.py | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/plugins/model-providers/deepseek/__init__.py b/plugins/model-providers/deepseek/__init__.py index f67146df113..525766f87eb 100644 --- a/plugins/model-providers/deepseek/__init__.py +++ b/plugins/model-providers/deepseek/__init__.py @@ -94,6 +94,7 @@ deepseek = DeepSeekProfile( "deepseek-reasoner", ), base_url="https://api.deepseek.com/v1", + default_aux_model="deepseek-chat", ) register_provider(deepseek) diff --git a/tests/plugins/model_providers/test_deepseek_profile.py b/tests/plugins/model_providers/test_deepseek_profile.py index c53e70070a8..8c316a38086 100644 --- a/tests/plugins/model_providers/test_deepseek_profile.py +++ b/tests/plugins/model_providers/test_deepseek_profile.py @@ -182,3 +182,26 @@ class TestDeepSeekFullKwargsIntegration: ) assert "reasoning_effort" not in kwargs assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {}) + + +class TestDeepSeekAuxModel: + """DeepSeek aux model is set on the profile so users stop seeing the + bogus 'No auxiliary LLM provider configured' warning (#26924). + + Pinned at the profile layer rather than the legacy + `_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict — new providers are + expected to set `default_aux_model` on `ProviderProfile`, and the + fallback dict only exists for providers that predate the profiles + system. + """ + + def test_profile_advertises_deepseek_chat(self, deepseek_profile): + assert deepseek_profile.default_aux_model == "deepseek-chat" + + def test_consumer_api_returns_deepseek_chat(self): + from agent.auxiliary_client import _get_aux_model_for_provider + assert _get_aux_model_for_provider("deepseek") == "deepseek-chat" + + def test_consumer_api_returns_non_empty(self): + from agent.auxiliary_client import _get_aux_model_for_provider + assert _get_aux_model_for_provider("deepseek") != "" From e90a52deafcca5c6b1fc06b0ef427348ec796077 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 22:53:29 -0700 Subject: [PATCH 36/41] chore(release): AUTHOR_MAP entries for batch salvage group 2 contributors Adds release-note attribution mappings for 10 contributors from the low-hanging-fruit salvage group 2 batch: - @shellybotmoyer (PR #26661, #25576) - @ether-btc (PR #26632) - @LifeJiggy (PR #26516) - @nekwo (PR #26481) - @flooryyyy (PR #26374) - @dgians (PR #26034, incl. zealy-tzco bot-committer alias) - @flanny7 (PR #27030) - @hermesagent26 (PR #26438) - @kriscolab (PR #26926, co-author on salvage commit) --- scripts/release.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 07952b63c0e..6bbc2ad4ae3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1096,6 +1096,23 @@ AUTHOR_MAP = { "4296245+matthewlai@users.noreply.github.com": "matthewlai", "109617724+0xchainer@users.noreply.github.com": "0xchainer", # PR #27154/27138/27147 salvage "201800237+kronexoi@users.noreply.github.com": "kronexoi", # PR #27167 salvage (Teams port fallback) + # batch salvage (May 2026 LHF run, group 2) + "shellybotmoyer@example.com": "shellybotmoyer", # PR #26661 (kanban --severity >=) + "coulson@shellybotmoyer.com": "shellybotmoyer", # PR #25576 (credential_pool ISO rehydrate) + "258858106+shellybotmoyer@users.noreply.github.com": "shellybotmoyer", + "33156212+ether-btc@users.noreply.github.com": "ether-btc", # PR #26632 (memory provider whitespace guard) + "Bloomtonjovish@gmail.com": "LifeJiggy", # PR #26516 (paste collapse logging) + "141562589+LifeJiggy@users.noreply.github.com": "LifeJiggy", + "beastant1@gmail.com": "nekwo", # PR #26481 (PS5.1 UTF-8 BOM) + "43717185+nekwo@users.noreply.github.com": "nekwo", + "67979730+flooryyyy@users.noreply.github.com": "flooryyyy", # PR #26374 (tool_trace error detection) + "188585318+dgians@users.noreply.github.com": "dgians", # PR #26034 (.ts/.py/.sh docs types) + "zealy@tz.co": "dgians", # PR #26034 (bot-committed by zealy-tzco under dgians' PR) + "mottei.survive@gmail.com": "flanny7", # PR #27030 (setup_open_webui python var) + "20530505+flanny7@users.noreply.github.com": "flanny7", + "hermesagent26@gmail.com": "hermesagent26", # PR #26438 (kimi model-name reasoning pad) + "276067471+hermesagent26@users.noreply.github.com": "hermesagent26", + "71590782+kriscolab@users.noreply.github.com": "kriscolab", # PR #26926 (deepseek default_aux_model) } From ea2ee51f0b40eac51e279e3ac746f99c2b38e4c0 Mon Sep 17 00:00:00 2001 From: kronexoi <201800237+kronexoi@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:49 +0300 Subject: [PATCH 37/41] fix(teams): fall back to default port on invalid port config --- plugins/platforms/teams/adapter.py | 11 ++++++++++- tests/gateway/test_teams.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index 990d03bb499..c71baeb9d93 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -116,6 +116,13 @@ def _parse_bool(value: Any, *, default: bool = False) -> bool: return default +def _coerce_port(value: Any, *, default: int = _DEFAULT_PORT) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + class _StaticAccessTokenProvider: """Minimal token-provider shim so outbound Graph delivery can reuse the shared client.""" @@ -623,7 +630,9 @@ class TeamsAdapter(BasePlatformAdapter): self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "") self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "") self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "") - self._port = int(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT))) + self._port = _coerce_port( + extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)) + ) self._app: Optional["App"] = None self._runner: Optional["web.AppRunner"] = None self._dedup = MessageDeduplicator(max_size=1000) diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index 34cd0ca3eed..58b8c35a5c2 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -283,6 +283,17 @@ class TestTeamsAdapterInit: adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant")) assert adapter._port == 5000 + def test_invalid_port_from_extra_falls_back_to_default(self): + adapter = TeamsAdapter( + _make_config(client_id="id", client_secret="secret", tenant_id="tenant", port="abc") + ) + assert adapter._port == 3978 + + def test_invalid_port_from_env_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("TEAMS_PORT", "abc") + adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant")) + assert adapter._port == 3978 + def test_platform_value(self): adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant")) assert adapter.platform.value == "teams" From e5f19af2a5cfed9ec7f6ea1e1f770f8a8b342de3 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 17 May 2026 00:47:21 -0400 Subject: [PATCH 38/41] feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage ` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm /scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes) --- scripts/install.ps1 | 732 ++++++++++++++---- .../tests/test-install-ps1-stage-protocol.ps1 | 134 ++++ 2 files changed, 736 insertions(+), 130 deletions(-) create mode 100644 scripts/tests/test-install-ps1-stage-protocol.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 53a1ea96486..4d7545ca689 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -17,11 +17,49 @@ param( [switch]$SkipSetup, [string]$Branch = "main", [string]$HermesHome = "$env:LOCALAPPDATA\hermes", - [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent" + [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent", + + # --- Stage protocol (additive; default invocation behaves as before) ---- + # See the "Stage protocol" section near the bottom of the file for the + # full contract. Intended for programmatic drivers (the desktop GUI's + # onboarding wizard, CI, future install.sh parity, etc.). CLI users + # running the canonical `irm | iex` one-liner never touch these flags. + [switch]$Manifest, + [string]$Stage, + [switch]$ProtocolVersion, + [switch]$NonInteractive, + [switch]$Json ) $ErrorActionPreference = "Stop" +# Suppress Invoke-WebRequest's per-chunk progress bar. Windows PowerShell +# 5.1's progress UI repaints synchronously on every received byte, which +# pegs CPU on a single core and throttles downloads by 10-100x (a 57MB +# PortableGit grab can take 5 minutes with progress on vs 20 seconds +# with progress off, on the same network). Every IWR call in this +# script is fire-and-forget so we never need to see the bar. Restored +# automatically when the script exits. +$ProgressPreference = "SilentlyContinue" + +# Force the console to UTF-8 so non-ASCII output from native commands +# (e.g. playwright's box-drawing progress bars and download banners, +# git's bullet glyphs, npm's check marks) renders correctly instead of +# as IBM437/Windows-1252 mojibake (sequences like 0xE2 0x95 0x94 box- +# drawing chars decoded under the legacy DOS codepage). This is a +# DISPLAY-only fix; the underlying bytes are already correct. We do +# NOT change the file's own encoding (it remains pure ASCII for PS 5.1 +# parser compatibility; see comments at the top of the entry-point +# dispatch). This affects only what the user sees in their terminal +# during this install run, and reverts automatically when the script +# exits and the host's console encoding is restored. +try { + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() +} catch { + # Some constrained PowerShell hosts disallow encoding mutation. + # Mojibake on output is then cosmetic-only, install still works. +} + # ============================================================================ # Configuration # ============================================================================ @@ -31,38 +69,43 @@ $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git" $PythonVersion = "3.11" $NodeVersion = "22" +# Stage-protocol version. Bumped only for genuinely breaking changes to the +# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a +# new stage does NOT bump this -- drivers iterate the manifest dynamically. +$InstallStageProtocolVersion = 1 + # ============================================================================ # Helper functions # ============================================================================ function Write-Banner { Write-Host "" - Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Magenta - Write-Host "│ ⚕ Hermes Agent Installer │" -ForegroundColor Magenta - Write-Host "├─────────────────────────────────────────────────────────┤" -ForegroundColor Magenta - Write-Host "│ An open source AI agent by Nous Research. │" -ForegroundColor Magenta - Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Magenta + Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta + Write-Host "| * Hermes Agent Installer |" -ForegroundColor Magenta + Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta + Write-Host "| An open source AI agent by Nous Research. |" -ForegroundColor Magenta + Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta Write-Host "" } function Write-Info { param([string]$Message) - Write-Host "→ $Message" -ForegroundColor Cyan + Write-Host "-> $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) - Write-Host "✓ $Message" -ForegroundColor Green + Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Warn { param([string]$Message) - Write-Host "⚠ $Message" -ForegroundColor Yellow + Write-Host "[!] $Message" -ForegroundColor Yellow } function Write-Err { param([string]$Message) - Write-Host "✗ $Message" -ForegroundColor Red + Write-Host "[X] $Message" -ForegroundColor Red } # ============================================================================ @@ -97,8 +140,22 @@ function Install-Uv { # Install uv Write-Info "Installing uv (fast Python package manager)..." try { + # Relax ErrorActionPreference around the nested astral installer. + # The astral installer (a separate `powershell -c "irm ... | iex"`) + # writes download progress to stderr. With $ErrorActionPreference + # = "Stop" set at the top of this script, PowerShell wraps stderr + # lines from native commands (which `powershell -c` is, from our + # perspective) as ErrorRecord objects when captured via 2>&1, then + # throws a terminating exception on the first one -- even though + # uv installs successfully and the child exits 0. Same fix + # pattern Test-Python uses for `uv python install`; verify success + # via Test-Path on the expected binary afterwards, which is more + # reliable than exit-code/stderr signal anyway. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null - + $ErrorActionPreference = $prevEAP + # Find the installed binary $uvExe = "$env:USERPROFILE\.local\bin\uv.exe" if (-not (Test-Path $uvExe)) { @@ -123,12 +180,78 @@ function Install-Uv { Write-Info "Try restarting your terminal and re-running" return $false } catch { - Write-Err "Failed to install uv" + # Restore EAP in case the try block threw before the assignment + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } } +# Refresh $env:Path from the User + Machine registry hives. Stage drivers +# invoke each stage in a fresh powershell process, but those processes +# inherit env from the parent driver shell, NOT from the registry. When +# an earlier stage (Stage-Git, Stage-Node, ...) installs a binary and +# pushes its directory into User PATH, the next child process's $env:Path +# is stale and the binary appears missing. This helper re-reads PATH +# from the registry so every Invoke-Stage starts from a fresh, up-to-date +# PATH view. Cheap (registry reads, no I/O elsewhere) and idempotent. +function Sync-EnvPath { + $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") +} + +# Re-discover uv without re-installing it. Cross-process stage drivers +# (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage +# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a +# prior process is not visible here. Later stages (Test-Python, +# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this +# at the top to populate $script:UvCmd from PATH or known install paths. +# Throws if uv is not findable -- the caller's stage then surfaces a +# clean error via the stage-driver's try/catch. Fast path is a single +# Get-Command call when uv is on PATH (the common case after Stage-Uv +# ran path-modifying installs in a sibling process). +function Resolve-UvCmd { + # Already resolved (default invocation path: Install-Uv ran earlier + # in the same process and set $script:UvCmd). + if ($script:UvCmd) { + if ($script:UvCmd -eq "uv") { + # "uv" on PATH -- verify it's still resolvable (PATH could have + # changed mid-session; cheap to recheck). + if (Get-Command uv -ErrorAction SilentlyContinue) { return } + } elseif (Test-Path $script:UvCmd) { + return + } + # Stale; fall through to re-discover. + } + + # Try PATH first (covers `winget install astral.uv`, manual installs, + # and the post-Install-Uv state where uv.exe lives in + # %USERPROFILE%\.local\bin which the installer added to PATH). + if (Get-Command uv -ErrorAction SilentlyContinue) { + $script:UvCmd = "uv" + return + } + + # Refresh PATH from registry in case the current process started before + # Install-Uv updated User PATH. + $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + if (Get-Command uv -ErrorAction SilentlyContinue) { + $script:UvCmd = "uv" + return + } + + # Check the well-known install locations the astral.sh installer drops + # uv into. Mirrors the probe order Install-Uv uses. + foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) { + if (Test-Path $uvPath) { + $script:UvCmd = $uvPath + return + } + } + + throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first." +} + function Test-Python { Write-Info "Checking Python $PythonVersion..." @@ -142,7 +265,7 @@ function Test-Python { } } catch { } - # Python not found — use uv to install it (no admin needed!) + # Python not found -- use uv to install it (no admin needed!) Write-Info "Python $PythonVersion not found, installing via uv..." try { # Temporarily relax ErrorActionPreference: uv writes download progress @@ -150,7 +273,7 @@ function Test-Python { # stderr. With $ErrorActionPreference = "Stop" (set at the top of this # script) PowerShell wraps stderr lines from native commands as # ErrorRecord objects when captured via 2>&1, then throws a terminating - # exception on the first one — even though uv exits 0 and Python was + # exception on the first one -- even though uv exits 0 and Python was # installed successfully. Verify success via `uv python find` # afterwards, which is the reliable signal regardless of exit-code # semantics or stderr noise. This fix was previously landed as @@ -170,7 +293,7 @@ function Test-Python { return $true } - # uv ran but Python still not findable — show what happened + # uv ran but Python still not findable -- show what happened if ($uvExitCode -ne 0) { Write-Warn "uv python install output:" Write-Host $uvOutput -ForegroundColor DarkGray @@ -195,7 +318,7 @@ function Test-Python { } catch { } } - # Fallback: try system python — but skip the Microsoft Store stub. + # Fallback: try system python -- but skip the Microsoft Store stub. # On Windows, %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe is a 0-byte # reparse-point stub that prints "Python was not found; run without # arguments to install from the Microsoft Store..." to stdout and exits @@ -244,17 +367,17 @@ function Install-Git { Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe which Hermes uses to run shell commands. - Priority order (deliberately simple — no winget, no registry, no system + Priority order (deliberately simple -- no winget, no registry, no system package manager): - 1. Existing ``git`` on PATH — use it as-is (the common fast path). + 1. Existing ``git`` on PATH -- use it as-is (the common fast path). 2. Download **PortableGit** from the official git-for-windows GitHub release (self-extracting 7z.exe) and unpack it to - ``%LOCALAPPDATA%\hermes\git`` — never touches system Git, never + ``%LOCALAPPDATA%\hermes\git`` -- never touches system Git, never requires admin, works even on locked-down machines and machines with a broken system Git install. **Why PortableGit, not MinGit:** MinGit is the minimal-automation - distribution and ships ONLY ``git.exe`` — no bash, no POSIX utilities. + distribution and ships ONLY ``git.exe`` -- no bash, no POSIX utilities. Hermes needs ``bash.exe`` to run shell commands. PortableGit is the full Git for Windows distribution without the installer UI; it ships ``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``, @@ -280,9 +403,9 @@ function Install-Git { } # Download PortableGit into $HermesHome\git. Always works as long as - # we can reach github.com — no admin, no winget, no reliance on the + # we can reach github.com -- no admin, no winget, no reliance on the # user's possibly-broken system Git install. - Write-Info "Git not found — downloading PortableGit to $HermesHome\git\ ..." + Write-Info "Git not found -- downloading PortableGit to $HermesHome\git\ ..." Write-Info "(no admin rights required; isolated from any system Git install)" try { @@ -294,7 +417,7 @@ function Install-Git { "64-bit" } } else { - # PortableGit does not ship a 32-bit build — fall back to MinGit 32-bit + # PortableGit does not ship a 32-bit build -- fall back to MinGit 32-bit # with a warning that bash-based features will be unavailable. "32-bit-mingit" } @@ -303,7 +426,7 @@ function Install-Git { $release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" } if ($arch -eq "32-bit-mingit") { - Write-Warn "32-bit Windows detected — PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine." + Write-Warn "32-bit Windows detected -- PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine." $assetPattern = "MinGit-*-32-bit.zip" $downloadIsZip = $true } elseif ($arch -eq "arm64") { @@ -428,7 +551,7 @@ function Set-GitBashEnvVar { # Standard system install locations as a final fallback. Note: # ProgramFiles(x86) can't be referenced via ${env:...} string interpolation - # because of the parens — use [Environment]::GetEnvironmentVariable(). + # because of the parens -- use [Environment]::GetEnvironmentVariable(). $candidates += "${env:ProgramFiles}\Git\bin\bash.exe" $pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" } @@ -443,7 +566,7 @@ function Set-GitBashEnvVar { } } - Write-Warn "Could not locate bash.exe — Hermes may not find Git Bash." + Write-Warn "Could not locate bash.exe -- Hermes may not find Git Bash." Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } @@ -467,26 +590,18 @@ function Test-Node { return $true } - Write-Info "Node.js not found — installing Node.js $NodeVersion LTS..." + Write-Info "Node.js not found -- installing Node.js $NodeVersion LTS..." - # Try winget first (cleanest on modern Windows) - if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Info "Installing via winget..." - try { - winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null - # Refresh PATH - $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - if (Get-Command node -ErrorAction SilentlyContinue) { - $version = node --version - Write-Success "Node.js $version installed via winget" - $script:HasNode = $true - return $true - } - } catch { } - } - - # Fallback: download binary zip to ~/.hermes/node/ - Write-Info "Downloading Node.js $NodeVersion binary..." + # Try the portable-zip path FIRST -- no UAC, no admin, no winget MSI. + # winget install OpenJS.NodeJS.LTS triggers a system-wide MSI install + # which prompts UAC (the dialog often appears minimized in the taskbar + # and the install silently waits for consent, looking like a hang). + # The portable zip path drops node.exe + npm into $HermesHome\node\ + # which is user-scoped and identical to how Install-Git handles + # PortableGit. Same UX guarantee: works on locked-down enterprise + # machines with no admin rights. + Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..." + Write-Info "(no admin rights required; isolated from any system Node install)" try { $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" @@ -506,10 +621,23 @@ function Test-Node { if ($extractedDir) { if (Test-Path "$HermesHome\node") { Remove-Item -Recurse -Force "$HermesHome\node" } Move-Item $extractedDir.FullName "$HermesHome\node" + + # Session PATH so the rest of this run sees node/npm. $env:Path = "$HermesHome\node;$env:Path" + # Persist to User PATH so fresh shells (and future stages + # in cross-process driver mode) see it. Matches the + # pattern Install-Git uses for PortableGit. + $nodeDir = "$HermesHome\node" + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } + if ($userPathItems -notcontains $nodeDir) { + $userPathItems += $nodeDir + [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") + } + $version = & "$HermesHome\node\node.exe" --version - Write-Success "Node.js $version installed to ~/.hermes/node/" + Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)" $script:HasNode = $true Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue @@ -518,10 +646,39 @@ function Test-Node { } } } catch { - Write-Warn "Download failed: $_" + Write-Warn "Portable Node.js download failed: $_" } - Write-Warn "Could not auto-install Node.js" + # Fallback: try winget (used to be primary, demoted because the MSI + # install triggers a UAC prompt that frequently appears minimized in + # the taskbar -- looks like a hang to users on stock Windows). + # Kept for environments where the portable download fails (proxy, + # locked firewall, etc.) but the user is willing to consent to UAC. + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..." + try { + # Relax EAP=Stop so stderr lines from winget don't get wrapped + # as ErrorRecords and short-circuit the 2>&1 pipe before we can + # check the post-condition. See the long comment in Install-Uv + # for the same pattern. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null + $ErrorActionPreference = $prevEAP + # Refresh PATH + $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + if (Get-Command node -ErrorAction SilentlyContinue) { + $version = node --version + Write-Success "Node.js $version installed via winget" + $script:HasNode = $true + return $true + } + } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + } + } + + Write-Info "Install manually: https://nodejs.org/en/download/" $script:HasNode = $false return $true @@ -657,7 +814,7 @@ function Install-Repository { if (Test-Path $InstallDir) { # Test-Path "$InstallDir\.git" returns True when .git is a file OR a - # directory OR a symlink OR a submodule-style gitfile — and also when + # directory OR a symlink OR a submodule-style gitfile -- and also when # it's a broken stub left over from a failed previous install (e.g. # a partial Remove-Item that couldn't delete a locked index.lock). # Validate the repo properly by asking git itself. Two checks @@ -704,7 +861,7 @@ function Install-Repository { # a partial uninstall used to lock the installer into the # "update" branch forever, emitting three ``fatal: not a git # repository`` errors and failing with "not in a git directory". - Write-Warn "Existing directory at $InstallDir is not a valid git repo — replacing it." + Write-Warn "Existing directory at $InstallDir is not a valid git repo -- replacing it." try { Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop } catch { @@ -750,7 +907,7 @@ function Install-Repository { # Fallback: download ZIP archive (bypasses git file I/O issues entirely) if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } - Write-Warn "Git clone failed — downloading ZIP archive instead..." + Write-Warn "Git clone failed -- downloading ZIP archive instead..." try { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip" $zipPath = "$env:TEMP\hermes-agent-$Branch.zip" @@ -841,14 +998,14 @@ function Install-Dependencies { $env:VIRTUAL_ENV = "$InstallDir\venv" } - # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # Hash-verified install (Tier 0) -- when uv.lock is present, prefer # `uv sync --locked`. The lockfile records SHA256 hashes for every # transitive dependency, so a compromised transitive (different hash # than what we shipped) is REJECTED by the resolver. This is the # *only* path that protects against the "direct dep is fine, but the # dep's dep got worm-poisoned overnight" failure mode. The # `uv pip install` tiers below re-resolve transitives fresh from PyPI - # without any hash verification — they exist to keep installs working + # without any hash verification -- they exist to keep installs working # when the lockfile is stale, missing, or out-of-sync with the # current extras spec, NOT because they're equivalent in posture. if (Test-Path "uv.lock") { @@ -863,7 +1020,7 @@ function Install-Dependencies { # # UV_PROJECT_ENVIRONMENT pins the sync target to our venv\. # Without it, modern uv (>=0.5) ignores VIRTUAL_ENV for `sync` - # and creates a sibling .venv\ inside the repo — leaving venv\ + # and creates a sibling .venv\ inside the repo -- leaving venv\ # empty and producing the broken state where `hermes.exe` exists # in the wrong directory and imports fail with ModuleNotFoundError. # (Mirrors the same flag in scripts/install.sh::install_deps.) @@ -872,7 +1029,7 @@ function Install-Dependencies { if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed (hash-verified via uv.lock)" $script:InstalledTier = "hash-verified (uv.lock)" - # Skip the rest of the tiered cascade — we already have a + # Skip the rest of the tiered cascade -- we already have a # complete, hash-verified install. $skipPipFallback = $true } else { @@ -880,22 +1037,22 @@ function Install-Dependencies { $skipPipFallback = $false } } else { - Write-Info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + Write-Info "uv.lock not found -- falling back to PyPI resolve (no hash verification)" $skipPipFallback = $false } # Install main package. Tiered fallback so a single flaky transitive # doesn't silently drop everything. Each tier's stdout/stderr is - # preserved — no Out-Null swallowing — so the user can see what failed. + # preserved -- no Out-Null swallowing -- so the user can see what failed. # - # Tier 1: [all] — the curated extra in pyproject.toml. + # Tier 1: [all] -- the curated extra in pyproject.toml. # Tier 2: [all] minus the currently-broken extras list ($brokenExtras). # Edit $brokenExtras below when something on PyPI breaks; this # lets users keep the rest of [all] when one transitive is # unavailable. The list of [all]'s contents is parsed from - # pyproject.toml at runtime — there is NO hand-mirrored copy + # pyproject.toml at runtime -- there is NO hand-mirrored copy # to drift out of sync. - # Tier 3: bare `.` — last-resort so at least the core CLI launches. + # Tier 3: bare `.` -- last-resort so at least the core CLI launches. # Currently-broken extras. Edit this list when an upstream package # gets quarantined / yanked / breaks resolution. Empty means everything @@ -969,11 +1126,21 @@ except Exception: if (-not (Test-Path $venvPython)) { throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" } + # Relax EAP=Stop while running the import probe. Python writes + # deprecation warnings and import-system info to stderr; under + # EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and + # throws even when the imports succeed. $LASTEXITCODE is the + # reliable signal (it's 0 iff the python invocation exited 0, + # regardless of what was written to stderr). + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" & $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { + $importExitCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($importExitCode -ne 0) { $sibling = "$InstallDir\.venv" $hint = if (Test-Path $sibling) { - "Detected sibling .venv\ at $sibling — uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv" + "Detected sibling .venv\ at $sibling -- uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv" } else { "Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" } @@ -982,19 +1149,27 @@ except Exception: Write-Success "Baseline imports verified in venv" } - # Verify the dashboard deps specifically — they're the most common thing + # Verify the dashboard deps specifically -- they're the most common thing # users hit and lazy-import errors from `hermes dashboard` are confusing. # If tier 1 failed (the common case), [web] was still picked up by tiers # 2-3; only tier 4 leaves you without it. $pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } if (Test-Path $pythonExe) { $webOk = $false + # Relax EAP=Stop while running the import probe; see the matching + # comment on the baseline-imports check above. Python writes + # deprecation warnings to stderr and we don't want those wrapped + # as ErrorRecords that silently force the "not importable" path + # even when fastapi/uvicorn are actually installed. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" try { & $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { $webOk = $true } } catch { } + $ErrorActionPreference = $prevEAP if (-not $webOk) { - Write-Warn "fastapi/uvicorn not importable — `hermes dashboard` will not work." + Write-Warn "fastapi/uvicorn not importable -- `hermes dashboard` will not work." Write-Info "Attempting targeted install of [web] extra as last resort..." & $UvCmd pip install -e ".[web]" if ($LASTEXITCODE -eq 0) { @@ -1099,7 +1274,7 @@ function Copy-ConfigTemplates { # flags the BOM as an invisible unicode character and refuses to # load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we # don't control which PowerShell version the user has. Go direct - # to .NET with an explicit UTF8Encoding($false) — BOM-free on every + # to .NET with an explicit UTF8Encoding($false) -- BOM-free on every # PowerShell version. $soulPath = "$HermesHome\SOUL.md" if (-not (Test-Path $soulPath)) { @@ -1155,7 +1330,7 @@ function Install-NodeDeps { # Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows # ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim). # Get-Command's default ordering picks whichever comes first in PATHEXT, - # and on many systems that's .ps1 — but .ps1 requires scripts to be + # and on many systems that's .ps1 -- but .ps1 requires scripts to be # enabled in PowerShell's execution policy, which most Windows users # don't have (the Restricted / RemoteSigned default blocks unsigned # .ps1 files). .cmd has no such restriction and works on every box. @@ -1165,7 +1340,7 @@ function Install-NodeDeps { # returned if we can't find a .cmd sibling. $npmCmd = Get-Command npm -ErrorAction SilentlyContinue if (-not $npmCmd) { - Write-Warn "npm not found on PATH — skipping Node.js dependencies." + Write-Warn "npm not found on PATH -- skipping Node.js dependencies." Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later." return } @@ -1176,7 +1351,7 @@ function Install-NodeDeps { Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)" $npmExe = $npmCmdSibling } else { - Write-Warn "Only npm.ps1 available — install may fail if script execution is disabled." + Write-Warn "Only npm.ps1 available -- install may fail if script execution is disabled." Write-Info " If it fails, either enable PS script execution or install Node via winget." } } @@ -1193,17 +1368,40 @@ function Install-NodeDeps { function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) { Push-Location $installDir try { - # Redirect ALL output streams to the log file via 2>&1 and then - # ``Tee-Object`` / ``Out-File``. Simpler approach: call npm - # with output redirected and inspect $LASTEXITCODE afterwards. - & $npmPath install --silent *> $logPath + # Stream npm's output to BOTH the console and the log file via + # Tee-Object. Previously this called ``& npm install --silent + # *> $logPath`` which redirected every stream to disk and left + # the user staring at a frozen "Installing..." line for the + # duration of the install. On a fresh VM that's 1-3 minutes + # of total silence, indistinguishable from a hang. + # + # Tee writes the live output to stdout AND $logPath; we still + # capture the exit code afterwards and surface diagnostics + # on failure. Note: 2>&1 merges npm's stderr into the success + # stream first because Tee-Object only sees the success + # stream of the pipeline. ForEach-Object { "$_" } coerces + # each item to a string so PowerShell's NativeCommandError + # formatter doesn't wrap stderr lines as alarming red blocks + # (cosmetic polish; the underlying text is unchanged). + # + # Relax EAP around the npm invocation: with EAP=Stop (set at + # the top of this script), PowerShell wraps stderr lines from + # native commands captured via 2>&1 as ErrorRecord objects and + # throws on the first one -- even though npm exited 0. This + # is the same issue Test-Python and Install-Uv work around + # for uv's stderr-emitting installer. Check success via + # $LASTEXITCODE, which is reliable regardless of stderr noise. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npmPath install --silent 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $logPath $code = $LASTEXITCODE + $ErrorActionPreference = $prevEAP if ($code -eq 0) { Write-Success "$label dependencies installed" Remove-Item -Force $logPath -ErrorAction SilentlyContinue return $true } - Write-Warn "$label npm install failed — exit code $code" + Write-Warn "$label npm install failed -- exit code $code" if (Test-Path $logPath) { $errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue) if ($errText) { @@ -1218,6 +1416,7 @@ function Install-NodeDeps { Write-Info "Run manually later: cd `"$installDir`"; npm install" return $false } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "$label npm install could not be launched: $_" return $false } finally { @@ -1236,7 +1435,7 @@ function Install-NodeDeps { # returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the # browser_* tools are silently filtered out of the agent's tool schema. # System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by - # agent-browser — it expects a Playwright-managed Chromium. + # agent-browser -- it expects a Playwright-managed Chromium. if ($browserNpmOk) { Write-Info "Installing browser engine (Playwright Chromium)..." # npx lives next to npm in the same bin dir. Prefer .cmd to dodge @@ -1252,19 +1451,54 @@ function Install-NodeDeps { if ($npxCmd) { $npxExe = $npxCmd.Source } } if (-not $npxExe) { - Write-Warn "npx not found — cannot install Playwright Chromium." + Write-Warn "npx not found -- cannot install Playwright Chromium." Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } else { $pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log" Push-Location $InstallDir try { - & $npxExe playwright install chromium *> $pwLog + # Playwright Chromium is ~170MB compressed and the + # download regularly takes 3-10 minutes on a fresh + # VM. Tee the output to console + log so the user + # sees download progress in real time instead of + # staring at a silent prompt that looks hung. See + # _Run-NpmInstall above for the same pattern and + # the rationale behind 2>&1 before the pipe. + Write-Info "(this can take several minutes -- streaming progress below)" + # --yes auto-accepts npx's "Need to install playwright@X.Y.Z" + # confirmation prompt. Without it, npx 7+ blocks on stdin + # waiting for a y/N answer that never comes when this is + # invoked through a pipeline (Tee-Object disconnects stdin + # from the user's TTY), and the install hangs indefinitely + # after printing "Need to install the following packages: + # playwright@X.Y.Z". + # + # Relax EAP around the playwright invocation: playwright + # emits a "Chromium downloaded to ..." success banner to + # stderr after a successful install. Under EAP=Stop, the + # 2>&1 merge wraps those stderr lines as ErrorRecord + # objects and throws -- causing this catch block to fire + # with a mangled banner as the error message even though + # the install actually succeeded. Check $LASTEXITCODE + # instead, which is the reliable signal. + # + # The ForEach-Object { "$_" } coercion BEFORE Tee-Object + # is a cosmetic polish: with bare 2>&1, PowerShell still + # renders stderr lines through its NativeCommandError + # formatter (the red "npx.cmd : ..." block). Coercing + # each pipeline item to a string strips that wrapper so + # the user sees clean playwright output instead of the + # alarming-looking error formatting. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npxExe --yes playwright install chromium 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $pwLog $pwCode = $LASTEXITCODE + $ErrorActionPreference = $prevEAP if ($pwCode -eq 0) { Write-Success "Playwright Chromium installed (browser tools ready)" Remove-Item -Force $pwLog -ErrorAction SilentlyContinue } else { - Write-Warn "Playwright Chromium install failed — exit code $pwCode" + Write-Warn "Playwright Chromium install failed -- exit code $pwCode" Write-Warn "Browser tools will not work until Chromium is installed." if (Test-Path $pwLog) { $pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue @@ -1280,6 +1514,7 @@ function Install-NodeDeps { Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } } catch { + if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "Playwright Chromium install could not be launched: $_" Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } finally { @@ -1307,7 +1542,7 @@ function Install-PlatformSdks { # which silently skips some messaging SDKs from [messaging]. # 2. `uv` creates the venv without pip. If a messaging SDK ends up # missing, the user can't `pip install python-telegram-bot` to - # recover — pip simply isn't in their venv. + # recover -- pip simply isn't in their venv. # # Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then # for each token set in .env, verify the matching SDK imports. If not, @@ -1387,7 +1622,7 @@ function Install-PlatformSdks { Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..." & $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { - Write-Warn "ensurepip failed — can't auto-install missing SDKs." + Write-Warn "ensurepip failed -- can't auto-install missing SDKs." Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`"" return } @@ -1412,20 +1647,28 @@ function Invoke-SetupWizard { Write-Info "Skipping setup wizard (-SkipSetup)" return } - + + if ($NonInteractive) { + # The setup wizard prompts for API keys, model choice, persona, etc. + # Non-interactive callers (GUI installer) own that UX themselves; let + # them drive it after install.ps1 returns. + Write-Info "Skipping setup wizard (non-interactive). Configure via the GUI or 'hermes setup'." + return + } + Write-Host "" Write-Info "Starting setup wizard..." Write-Host "" - + Push-Location $InstallDir - + # Run hermes setup using the venv Python directly (no activation needed) if (-not $NoVenv) { & ".\venv\Scripts\python.exe" -m hermes_cli.main setup } else { python -m hermes_cli.main setup } - + Pop-Location } @@ -1455,13 +1698,20 @@ function Start-GatewayIfConfigured { Write-Info "WhatsApp is enabled but not yet paired." Write-Info "Running 'hermes whatsapp' to pair via QR code..." Write-Host "" - $response = Read-Host "Pair WhatsApp now? [Y/n]" - if ($response -eq "" -or $response -match "^[Yy]") { - try { - & $hermesCmd whatsapp - } catch { - # Expected after pairing completes + # Non-interactive callers (GUI installer, CI) skip the QR-pair prompt; + # WhatsApp pairing requires a human looking at a phone camera, so the + # downstream UI is responsible for surfacing this when it makes sense. + if (-not $NonInteractive) { + $response = Read-Host "Pair WhatsApp now? [Y/n]" + if ($response -eq "" -or $response -match "^[Yy]") { + try { + & $hermesCmd whatsapp + } catch { + # Expected after pairing completes + } } + } else { + Write-Info "Skipping WhatsApp pairing prompt (non-interactive)." } } @@ -1469,6 +1719,16 @@ function Start-GatewayIfConfigured { Write-Info "Messaging platform token detected!" Write-Info "The gateway handles messaging platforms and cron job execution." Write-Host "" + + # In non-interactive mode the gateway lifecycle is the caller's problem + # (the GUI manages its own gateway process, CI doesn't want background + # services on the build agent, etc.). Treat it like the user declined. + if ($NonInteractive) { + Write-Info "Skipping gateway autostart prompt (non-interactive)." + Write-Info "Start the gateway later with: hermes gateway" + return + } + $response = Read-Host "Would you like to start the gateway now? [Y/n]" if ($response -eq "" -or $response -match "^[Yy]") { @@ -1492,13 +1752,13 @@ function Start-GatewayIfConfigured { function Write-Completion { Write-Host "" - Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Green - Write-Host "│ ✓ Installation Complete! │" -ForegroundColor Green - Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Green + Write-Host "+---------------------------------------------------------+" -ForegroundColor Green + Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green + Write-Host "+---------------------------------------------------------+" -ForegroundColor Green Write-Host "" # Show file locations - Write-Host "📁 Your files:" -ForegroundColor Cyan + Write-Host "* Your files:" -ForegroundColor Cyan Write-Host "" Write-Host " Config: " -NoNewline -ForegroundColor Yellow Write-Host "$HermesHome\config.yaml" @@ -1510,9 +1770,9 @@ function Write-Completion { Write-Host "$HermesHome\hermes-agent\" Write-Host "" - Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host "---------------------------------------------------------" -ForegroundColor Cyan Write-Host "" - Write-Host "🚀 Commands:" -ForegroundColor Cyan + Write-Host "* Commands:" -ForegroundColor Cyan Write-Host "" Write-Host " hermes " -NoNewline -ForegroundColor Green Write-Host "Start chatting" @@ -1528,9 +1788,9 @@ function Write-Completion { Write-Host "Update to latest version" Write-Host "" - Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan + Write-Host "---------------------------------------------------------" -ForegroundColor Cyan Write-Host "" - Write-Host "⚡ Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow + Write-Host "[*] Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow Write-Host "" if (-not $HasNode) { @@ -1548,18 +1808,136 @@ function Write-Completion { } # ============================================================================ -# Main +# Stage protocol +# ============================================================================ +# +# install.ps1 supports a small, stable "stage protocol" that lets programmatic +# callers (the desktop GUI's onboarding wizard, CI, future install.sh, etc.) +# drive the install one step at a time and surface progress/errors with their +# own UI. CLI users running the canonical `irm | iex` one-liner never +# encounter this -- default invocation behaves exactly as before. +# +# Entry points: +# +# install.ps1 Interactive install (today's behavior). +# install.ps1 -ProtocolVersion Emit the protocol version integer. +# install.ps1 -Manifest Emit the stage manifest as JSON. +# install.ps1 -Stage Run one stage and emit its result. +# install.ps1 -NonInteractive Disable all Read-Host prompts (also +# skips the setup wizard and the gateway +# autostart prompt). Can be combined +# with default invocation to do a full +# non-interactive install. +# install.ps1 -Json Emit machine-readable JSON instead of +# the human-readable success banner at +# the end of a full install. +# +# Manifest schema (the JSON returned by -Manifest): +# +# { +# "protocol_version": 1, +# "stages": [ +# { +# "name": "uv", +# "title": "Installing uv package manager", +# "category": "prereqs", +# "needs_user_input": false +# }, +# ... +# ] +# } +# +# Stage result (the JSON written by -Stage ): +# +# { +# "stage": "uv", +# "ok": true, +# "skipped": false, +# "reason": null, +# "duration_ms": 1234 +# } +# +# Exit codes: +# +# 0 -- success (stage ran, or stage was deliberately skipped). +# 1 -- generic failure; the stage threw. +# 2 -- unknown stage name passed to -Stage. +# +# Adding a stage: +# +# 1. Append an entry to $InstallStages below. +# 2. Make sure the worker function it points at is idempotent and respects +# $NonInteractive when it has prompts. Add it before "configure" +# (the wizard) or "gateway" (autostart) if it should run unconditionally; +# after those if it's optional post-install glue. +# 3. Do NOT bump $InstallStageProtocolVersion -- adding stages is additive. +# Drivers iterate the manifest dynamically. +# # ============================================================================ -function Main { - Write-Banner +# Stage definitions -- the single source of truth. Each entry maps a stable +# stage name (the API contract drivers depend on) to the worker function that +# implements it. ``Title`` is what UIs show; ``Category`` lets UIs group +# stages; ``NeedsUserInput`` tells UIs "this stage prompts -- either skip it +# or arrange to provide answers another way." +$InstallStages = @( + @{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" } + @{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" } + @{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" } + @{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" } + @{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" } + @{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" } + @{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" } + @{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" } + @{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" } + @{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" } + @{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" } + @{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" } + # Interactive stages. In non-interactive mode these become no-ops; the + # caller (GUI / CI) handles the equivalent UX themselves. + @{ Name = "configure"; Title = "Configuring API keys and models"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Configure" } + @{ Name = "gateway"; Title = "Starting messaging gateway"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Gateway" } +) +# Stage workers -- thin wrappers that delegate to the existing Install-* / +# Test-* / Invoke-* functions while preserving their error semantics. Kept +# as a separate layer so the existing functions remain callable directly +# (helpful for one-off recovery: ``. install.ps1; Install-Venv``). +# +# Stages that depend on uv (anything after Stage-Uv) call Resolve-UvCmd +# first so they work in cross-process driver mode where $script:UvCmd +# set by Stage-Uv in a sibling powershell process is not visible here. +# Resolve-UvCmd is a fast no-op when $script:UvCmd is already populated +# (the default-invocation case where Main runs everything in one +# process), and throws cleanly if uv truly isn't installed yet. +function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } } +function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } } +function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } } +function Stage-Node { [void](Test-Node) } +function Stage-SystemPackages { Install-SystemPackages } +function Stage-Repository { Install-Repository } +function Stage-Venv { Resolve-UvCmd; Install-Venv } +function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } +function Stage-NodeDeps { Install-NodeDeps } +function Stage-Path { Set-PathVariable } +function Stage-ConfigTemplates { Copy-ConfigTemplates } +function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } +function Stage-Configure { Invoke-SetupWizard } +function Stage-Gateway { Start-GatewayIfConfigured } + +function Get-InstallStage { + param([string]$Name) + foreach ($s in $InstallStages) { + if ($s.Name -eq $Name) { return $s } + } + return $null +} + +function Step-OutOfInstallDir { # Windows refuses to delete a directory any shell is currently cd'd - # inside — and silently leaves orphan files behind, which then wedge - # "is this a valid git repo" probes on re-install. If the current - # working dir is under $InstallDir, step out to the user's home - # BEFORE doing anything else. Harmless when the user ran the - # installer from somewhere else. + # inside -- and silently leaves orphan files behind, which then wedge + # "is this a valid git repo" probes on re-install. Harmless when the + # caller ran the installer from somewhere else. try { $currentResolved = (Get-Location).ProviderPath $installResolved = $null @@ -1571,36 +1949,130 @@ function Main { Set-Location $env:USERPROFILE } } catch {} - - if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" } - if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" } - if (-not (Install-Git)) { throw "Git not available and auto-install failed — install from https://git-scm.com/download/win then re-run" } - # Test-Node always returns $true (sets $script:HasNode on success, emits a - # warning on failure and continues so non-browser installs still work). - # Cast to [void] so the bare return value doesn't print "True" to the - # console between the "Node found" line and the next installer step. - [void](Test-Node) - Install-SystemPackages # ripgrep + ffmpeg in one step - - Install-Repository - Install-Venv - Install-Dependencies - Install-NodeDeps - Set-PathVariable - Copy-ConfigTemplates - Invoke-SetupWizard - Install-PlatformSdks - Start-GatewayIfConfigured - - Write-Completion } -# Wrap in try/catch so errors don't kill the terminal when run via: -# irm https://...install.ps1 | iex -# (exit/throw inside iex kills the entire PowerShell session) +function Invoke-Stage { + param( + [Parameter(Mandatory=$true)] [hashtable]$StageDef + ) + + # Refresh PATH from registry so this stage sees binaries installed by + # prior stages, even when each stage runs in its own powershell process. + # No-op in cost-relevant cases (default invocation path syncs once per + # foreach pass; cross-process drivers get the necessary freshening). + Sync-EnvPath + + $start = [DateTime]::UtcNow + $result = @{ + stage = $StageDef.Name + ok = $false + skipped = $false + reason = $null + duration_ms = 0 + } + + try { + & $StageDef.Worker + $result.ok = $true + } catch { + $result.ok = $false + $result.reason = "$_" + throw + } finally { + $result.duration_ms = [int]([DateTime]::UtcNow - $start).TotalMilliseconds + if ($Json -or $Stage) { + # In stage-driver mode every stage emits a JSON line so the + # caller can stream progress. In default interactive mode we + # stay silent here (the worker already wrote human output). + $result | ConvertTo-Json -Compress | Write-Output + } + } +} + +# ============================================================================ +# Main +# ============================================================================ + +function Invoke-AllStages { + Step-OutOfInstallDir + foreach ($s in $InstallStages) { + Invoke-Stage -StageDef $s + } +} + +function Main { + Write-Banner + Invoke-AllStages + if (-not $Json) { + Write-Completion + } else { + @{ ok = $true; protocol_version = $InstallStageProtocolVersion } | ConvertTo-Json -Compress | Write-Output + } +} + +# ---------------------------------------------------------------------------- +# Entry-point dispatch +# ---------------------------------------------------------------------------- +# +# All branches funnel through one try/catch so errors don't kill an `irm | +# iex` PowerShell session, and so failures in stage-driver mode produce a +# structured JSON error frame instead of a bare exception. + try { + if ($ProtocolVersion) { + Write-Output $InstallStageProtocolVersion + exit 0 + } + + if ($Manifest) { + $payload = @{ + protocol_version = $InstallStageProtocolVersion + stages = @($InstallStages | ForEach-Object { + @{ + name = $_.Name + title = $_.Title + category = $_.Category + needs_user_input = $_.NeedsUserInput + } + }) + } + $payload | ConvertTo-Json -Depth 5 -Compress | Write-Output + exit 0 + } + + if ($Stage) { + $def = Get-InstallStage -Name $Stage + if (-not $def) { + $err = @{ + ok = $false + stage = $Stage + reason = "unknown stage: $Stage. Run install.ps1 -Manifest to list valid stages." + } + $err | ConvertTo-Json -Compress | Write-Output + exit 2 + } + Step-OutOfInstallDir + Invoke-Stage -StageDef $def + exit 0 + } + + # Default: full install (today's behavior, plus optional -NonInteractive + # and -Json layered on by the params above). Main } catch { + if ($Json -or $Stage) { + # Stage-driver mode: caller wants JSON they can parse. Emit a + # structured error frame and exit non-zero. + $err = @{ + ok = $false + stage = if ($Stage) { $Stage } else { $null } + reason = "$_" + } + $err | ConvertTo-Json -Compress | Write-Output + exit 1 + } + + # Interactive mode: keep today's friendly recovery hint. Write-Host "" Write-Err "Installation failed: $_" Write-Host "" diff --git a/scripts/tests/test-install-ps1-stage-protocol.ps1 b/scripts/tests/test-install-ps1-stage-protocol.ps1 new file mode 100644 index 00000000000..4e2e7ea256e --- /dev/null +++ b/scripts/tests/test-install-ps1-stage-protocol.ps1 @@ -0,0 +1,134 @@ +# Smoke tests for the install.ps1 stage protocol. +# +# Run from a PowerShell prompt: +# +# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1 +# +# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest, +# unknown -Stage handling). They DO NOT actually run any install stages — +# those have heavy side effects (winget, git clone, pip install, PATH writes) +# and are out of scope for a unit smoke test. All three metadata commands +# below return without invoking Main / Invoke-AllStages. +# +# To exercise real install stages, drive the script from a clean VM. + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) +$installScript = Join-Path $repoRoot "scripts\install.ps1" + +if (-not (Test-Path $installScript)) { + throw "Could not locate install.ps1 at $installScript" +} + +$failures = 0 +function Assert-Equal { + param([Parameter(Mandatory=$true)] $Expected, + [Parameter(Mandatory=$true)] $Actual, + [Parameter(Mandatory=$true)] [string]$Label) + if ($Expected -ne $Actual) { + Write-Host "FAIL: $Label" -ForegroundColor Red + Write-Host " expected: $Expected" + Write-Host " actual: $Actual" + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} +function Assert-True { + param([Parameter(Mandatory=$true)] $Condition, + [Parameter(Mandatory=$true)] [string]$Label) + if (-not $Condition) { + Write-Host "FAIL: $Label" -ForegroundColor Red + $script:failures++ + } else { + Write-Host "OK: $Label" -ForegroundColor Green + } +} + +# ----------------------------------------------------------------------------- +# Test: -ProtocolVersion emits a single integer +# ----------------------------------------------------------------------------- +Write-Host "" +Write-Host "-- -ProtocolVersion --" +$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion +Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0" +Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)" + +# ----------------------------------------------------------------------------- +# Test: -Manifest emits valid JSON with expected shape +# ----------------------------------------------------------------------------- +Write-Host "" +Write-Host "-- -Manifest --" +$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest +Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0" + +$manifest = $null +try { + $manifest = $manifestJson | ConvertFrom-Json + Assert-True $true -Label "-Manifest output parses as JSON" +} catch { + Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)" +} + +if ($manifest) { + Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) ` + -Label "manifest.protocol_version is an integer" + Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty" + + # Every stage has the four required fields + $allValid = $true + foreach ($stage in $manifest.stages) { + foreach ($field in @("name", "title", "category", "needs_user_input")) { + if (-not ($stage.PSObject.Properties.Name -contains $field)) { + Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red + $allValid = $false + } + } + } + Assert-True $allValid -Label "every stage has name/title/category/needs_user_input" + + # Specific stage names that the GUI driver will rely on + $names = $manifest.stages | ForEach-Object { $_.name } + foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) { + Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'" + } + + # The two known-interactive stages must declare needs_user_input + $interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name } + Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input" + Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input" +} + +# ----------------------------------------------------------------------------- +# Test: unknown stage name -> exit 2, structured JSON error +# ----------------------------------------------------------------------------- +Write-Host "" +Write-Host "-- -Stage with unknown name --" +$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist" +Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2" + +$errFrame = $null +try { + $errFrame = $errOutput | ConvertFrom-Json + Assert-True $true -Label "unknown-stage output parses as JSON" +} catch { + Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)" +} + +if ($errFrame) { + Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false" + Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name" + Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why" +} + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +Write-Host "" +if ($failures -gt 0) { + Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red + exit 1 +} else { + Write-Host "All smoke tests passed." -ForegroundColor Green + exit 0 +} From c0b64f087750ea4a9fe11e32ad9cce21e9857e2d Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 17 May 2026 01:23:59 -0400 Subject: [PATCH 39/41] fix(install.ps1): address Copilot review on #27224 Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, #2). When -Stage ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot #3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass. --- scripts/install.ps1 | 49 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 4d7545ca689..f2914575e84 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -139,6 +139,11 @@ function Install-Uv { # Install uv Write-Info "Installing uv (fast Python package manager)..." + # Capture EAP outside the try block so the catch's restore call always + # has a meaningful value -- if the assignment lived inside try and the + # try body threw before reaching it, the catch would see $prevEAP + # unset and leave EAP at whatever the previous protected call set. + $prevEAP = $ErrorActionPreference try { # Relax ErrorActionPreference around the nested astral installer. # The astral installer (a separate `powershell -c "irm ... | iex"`) @@ -151,7 +156,6 @@ function Install-Uv { # pattern Test-Python uses for `uv python install`; verify success # via Test-Path on the expected binary afterwards, which is more # reliable than exit-code/stderr signal anyway. - $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null $ErrorActionPreference = $prevEAP @@ -267,6 +271,9 @@ function Test-Python { # Python not found -- use uv to install it (no admin needed!) Write-Info "Python $PythonVersion not found, installing via uv..." + # Capture EAP outside the try block so the catch's restore call always + # has a meaningful value (see Install-Uv for the full rationale). + $prevEAP = $ErrorActionPreference try { # Temporarily relax ErrorActionPreference: uv writes download progress # ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to @@ -278,7 +285,6 @@ function Test-Python { # afterwards, which is the reliable signal regardless of exit-code # semantics or stderr noise. This fix was previously landed as # commit ec1714e71 and then lost in a release squash; reapplied here. - $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" $uvOutput = & $UvCmd python install $PythonVersion 2>&1 $uvExitCode = $LASTEXITCODE @@ -656,12 +662,14 @@ function Test-Node { # locked firewall, etc.) but the user is willing to consent to UAC. if (Get-Command winget -ErrorAction SilentlyContinue) { Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..." + # Capture EAP outside the try block so the catch's restore call always + # has a meaningful value (see Install-Uv for the full rationale). + $prevEAP = $ErrorActionPreference try { # Relax EAP=Stop so stderr lines from winget don't get wrapped # as ErrorRecords and short-circuit the 2>&1 pipe before we can # check the post-condition. See the long comment in Install-Uv # for the same pattern. - $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null $ErrorActionPreference = $prevEAP @@ -1367,6 +1375,9 @@ function Install-NodeDeps { # it works uniformly for npm.cmd, npx.cmd, and bare .exe files. function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) { Push-Location $installDir + # Capture EAP outside the try block so the catch's restore call always + # has a meaningful value (see Install-Uv for the full rationale). + $prevEAP = $ErrorActionPreference try { # Stream npm's output to BOTH the console and the log file via # Tee-Object. Previously this called ``& npm install --silent @@ -1391,7 +1402,6 @@ function Install-NodeDeps { # is the same issue Test-Python and Install-Uv work around # for uv's stderr-emitting installer. Check success via # $LASTEXITCODE, which is reliable regardless of stderr noise. - $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & $npmPath install --silent 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $logPath $code = $LASTEXITCODE @@ -1456,6 +1466,10 @@ function Install-NodeDeps { } else { $pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log" Push-Location $InstallDir + # Capture EAP outside the try block so the catch's restore call + # always has a meaningful value (see Install-Uv for the full + # rationale). + $prevEAP = $ErrorActionPreference try { # Playwright Chromium is ~170MB compressed and the # download regularly takes 3-10 minutes on a fresh @@ -1489,7 +1503,6 @@ function Install-NodeDeps { # each pipeline item to a string strips that wrapper so # the user sees clean playwright output instead of the # alarming-looking error formatting. - $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & $npxExe --yes playwright install chromium 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $pwLog $pwCode = $LASTEXITCODE @@ -1985,6 +1998,13 @@ function Invoke-Stage { # caller can stream progress. In default interactive mode we # stay silent here (the worker already wrote human output). $result | ConvertTo-Json -Compress | Write-Output + # Tell the entry-point catch that we've already emitted a + # frame for this failure (when $result.ok = $false), so it + # doesn't double-emit a second JSON object and break the + # one-line-per-stage contract the driver protocol promises. + if (-not $result.ok) { + $script:_StageEmittedErrorFrame = $true + } } } } @@ -2062,13 +2082,20 @@ try { } catch { if ($Json -or $Stage) { # Stage-driver mode: caller wants JSON they can parse. Emit a - # structured error frame and exit non-zero. - $err = @{ - ok = $false - stage = if ($Stage) { $Stage } else { $null } - reason = "$_" + # structured error frame and exit non-zero -- BUT only if + # Invoke-Stage didn't already emit one for this same failure. + # The inner finally emits the authoritative per-stage frame + # (with duration_ms + skipped fields); a second emit here + # would produce two concatenated JSON objects on stdout and + # break drivers that parse one-line-per-invocation. + if (-not $script:_StageEmittedErrorFrame) { + $err = @{ + ok = $false + stage = if ($Stage) { $Stage } else { $null } + reason = "$_" + } + $err | ConvertTo-Json -Compress | Write-Output } - $err | ConvertTo-Json -Compress | Write-Output exit 1 } From 3925be2791038e29fc9d1fc10c3fd403a8d5bed7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 22:52:28 -0700 Subject: [PATCH 40/41] fix(install.ps1): trim completion banner + strip em-dash in test Address the two cosmetic items from review: - Completion banner middle line was 62 chars vs 59-char top/bottom borders (replacing the 1-char checkmark with [OK] added width that wasn't reflected in the trailing whitespace). Drop 3 trailing spaces. - Smoke test file had a single em-dash in a comment -- the only non-ASCII byte across both files. Replace with -- for consistency with install.ps1's pure-ASCII goal. --- scripts/install.ps1 | 2 +- scripts/tests/test-install-ps1-stage-protocol.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f2914575e84..b23ac54f736 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1766,7 +1766,7 @@ function Start-GatewayIfConfigured { function Write-Completion { Write-Host "" Write-Host "+---------------------------------------------------------+" -ForegroundColor Green - Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green + Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green Write-Host "+---------------------------------------------------------+" -ForegroundColor Green Write-Host "" diff --git a/scripts/tests/test-install-ps1-stage-protocol.ps1 b/scripts/tests/test-install-ps1-stage-protocol.ps1 index 4e2e7ea256e..b8fa5271ce6 100644 --- a/scripts/tests/test-install-ps1-stage-protocol.ps1 +++ b/scripts/tests/test-install-ps1-stage-protocol.ps1 @@ -5,7 +5,7 @@ # powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1 # # These tests only exercise the metadata surface (-ProtocolVersion, -Manifest, -# unknown -Stage handling). They DO NOT actually run any install stages — +# unknown -Stage handling). They DO NOT actually run any install stages -- # those have heavy side effects (winget, git clone, pip install, PATH writes) # and are out of scope for a unit smoke test. All three metadata commands # below return without invoking Main / Invoke-AllStages. From fb138d91ca34c3e2e49ce67f3187da6feeedbbdd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 22:54:56 -0700 Subject: [PATCH 41/41] fix(install.ps1): Stage-Node honest reporting + reject empty -Stage Two protocol-correctness gaps from review: 1. Stage-Node used [void](Test-Node) which discarded Test-Node's return value, so the JSON frame always reported ok=true even when Node install fully failed. A GUI driver consuming the manifest couldn't tell 'node ready' from 'node missing'. Wire a soft-skip channel ($script:_StageSkippedReason) that workers can populate to surface 'ran, but the thing it was supposed to set up is not available' as skipped=true with a reason in the JSON, without aborting the install (Node is optional -- browser tools degrade gracefully, matches Write-Completion's existing 'Note: Node.js could not be installed' behavior). Reset before each stage so a prior reason can't leak. 2. The -Stage dispatch used 'if ($Stage)' which is falsy for empty string, so 'install.ps1 -Stage ""' fell through to Main and silently kicked off a full destructive install. Switch to PSBoundParameters.ContainsKey('Stage') so an explicit empty value surfaces as unknown-stage exit 2 with a structured JSON frame, the way every other bad stage name does. --- scripts/install.ps1 | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b23ac54f736..c774e9a860c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1926,7 +1926,17 @@ $InstallStages = @( function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } } function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } } function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } } -function Stage-Node { [void](Test-Node) } +# Node is optional (browser tools degrade gracefully without it). Surface +# failure to the JSON contract as skipped=true / reason rather than ok=true, +# so a GUI driver consuming the manifest can distinguish "node ready" from +# "node missing". Install flow continues either way -- matches the +# existing Write-Completion behavior that prints a "Note: Node.js could +# not be installed" hint instead of aborting. +function Stage-Node { + if (-not (Test-Node)) { + $script:_StageSkippedReason = "Node.js not available; browser tools will be unavailable until node is installed manually from https://nodejs.org/en/download/" + } +} function Stage-SystemPackages { Install-SystemPackages } function Stage-Repository { Install-Repository } function Stage-Venv { Resolve-UvCmd; Install-Venv } @@ -1975,6 +1985,15 @@ function Invoke-Stage { # foreach pass; cross-process drivers get the necessary freshening). Sync-EnvPath + # Per-stage soft-skip channel. A worker can populate + # $script:_StageSkippedReason to surface "ran, but the thing it was + # supposed to set up is not available" as skipped=true in the JSON + # frame, without throwing. Used by Stage-Node so the install flow + # doesn't abort when an optional capability is missing while still + # being honest in the protocol contract. Reset before each stage so + # a prior stage's reason can never leak into a later stage's frame. + $script:_StageSkippedReason = $null + $start = [DateTime]::UtcNow $result = @{ stage = $StageDef.Name @@ -1987,6 +2006,10 @@ function Invoke-Stage { try { & $StageDef.Worker $result.ok = $true + if ($script:_StageSkippedReason) { + $result.skipped = $true + $result.reason = $script:_StageSkippedReason + } } catch { $result.ok = $false $result.reason = "$_" @@ -2060,7 +2083,12 @@ try { exit 0 } - if ($Stage) { + # Use PSBoundParameters rather than $Stage truthiness so that an + # explicit `-Stage ""` from a misbehaving driver doesn't fall through + # to the full-install Main path and silently kick off a destructive + # operation. Empty string is a contract violation; surface it as + # unknown-stage exit 2 with a structured JSON frame. + if ($PSBoundParameters.ContainsKey("Stage")) { $def = Get-InstallStage -Name $Stage if (-not $def) { $err = @{