diff --git a/README.md b/README.md index efe5515f4d8..abdc66245f3 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. --- 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]) 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 diff --git a/cli.py b/cli.py index 241d41e9fcd..00b3af44df6 100644 --- a/cli.py +++ b/cli.py @@ -12546,6 +12546,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': @@ -12713,6 +12714,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) 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", } 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/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/hermes_cli/config.py b/hermes_cli/config.py index e1b1fbfb52a..22656b5c81e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1313,6 +1313,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/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) 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: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d15a6de09d4..4503d31da2a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1089,7 +1089,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"): @@ -1111,10 +1111,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/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 451bb3b4964..4cf3198cb40 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -58,8 +58,8 @@ def _read_message_body( if file_path == "-": return sys.stdin.read() try: - return Path(file_path).read_text() - except OSError as exc: + return Path(file_path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) sys.exit(_USAGE_EXIT) 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/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/run_agent.py b/run_agent.py index 1dd4219b22e..329163f12b3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2022,7 +2022,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() @@ -4347,6 +4347,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, @@ -4358,6 +4373,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" @@ -9169,6 +9185,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, @@ -10096,6 +10113,7 @@ class AIAgent: "openai/", "x-ai/", "google/gemini-2", + "google/gemma-4", "qwen/qwen3", "tencent/hy3-preview", "xiaomi/", @@ -10393,12 +10411,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: diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5ed7aa755fd..c774e9a860c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,4 +1,4 @@ -# ============================================================================ +ο»Ώ# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). @@ -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 } # ============================================================================ @@ -96,9 +139,27 @@ 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"`) + # 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. + $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 +184,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,20 +269,22 @@ 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..." + # 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 # 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 # commit ec1714e71 and then lost in a release squash; reapplied here. - $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" $uvOutput = & $UvCmd python install $PythonVersion 2>&1 $uvExitCode = $LASTEXITCODE @@ -170,7 +299,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 +324,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 +373,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 +409,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 +423,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 +432,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 +557,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 +572,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 +596,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 +627,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 +652,41 @@ 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)..." + # 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. + $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 +822,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 +869,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 +915,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 +1006,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 +1028,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 +1037,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 +1045,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 +1134,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 +1157,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 +1282,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 +1338,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 +1348,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 +1359,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." } } @@ -1192,18 +1375,43 @@ 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 { - # 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. + $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 +1426,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 +1445,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 +1461,57 @@ 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 + # 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 { - & $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. + $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 +1527,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 +1555,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 +1635,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 +1660,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 +1711,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 +1732,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 +1765,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 +1783,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 +1801,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 +1821,146 @@ 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" } } +# 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 } +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 +1972,162 @@ 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 + + # 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 + ok = $false + skipped = $false + reason = $null + duration_ms = 0 + } + + try { + & $StageDef.Worker + $result.ok = $true + if ($script:_StageSkippedReason) { + $result.skipped = $true + $result.reason = $script:_StageSkippedReason + } + } 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 + # 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 + } + } + } +} + +# ============================================================================ +# 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 + } + + # 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 = @{ + 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 -- 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 + } + exit 1 + } + + # Interactive mode: keep today's friendly recovery hint. Write-Host "" Write-Err "Installation failed: $_" Write-Host "" diff --git a/scripts/release.py b/scripts/release.py index 5d4cb3eb82f..6bbc2ad4ae3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1086,6 +1086,33 @@ 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", + "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) } 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() { 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..b8fa5271ce6 --- /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 +} 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 = [] 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/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" 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" 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/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/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") != "" 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." + ) 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", 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 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..047ad67912f 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,46 @@ 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('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('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) + }) +}) + 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..ef3a1816975 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -9,12 +9,40 @@ 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_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 -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_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, '') -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_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, '') + +export const hasAnsi = (s: string) => s.includes(ESC) const renderEstimateLine = (line: string) => { const trimmed = line.trim() 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/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 56fe8a13715..ee670bfb3b3 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/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). 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 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 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? | |--------|---------|----------| diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 6b8c0db9c91..4843cd557cc 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 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: 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). :::