From 4c0460a902ad45534e1a8a05a83926feaedb4915 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 27 Jul 2026 13:14:27 -0300 Subject: [PATCH 1/3] fix(skill): update Himalaya skill to v2.0.0 config schema (#72734) --- skills/email/himalaya/SKILL.md | 81 ++++----- .../himalaya/references/configuration.md | 156 +++++++----------- 2 files changed, 96 insertions(+), 141 deletions(-) diff --git a/skills/email/himalaya/SKILL.md b/skills/email/himalaya/SKILL.md index c35f26464846..8962ed054dac 100644 --- a/skills/email/himalaya/SKILL.md +++ b/skills/email/himalaya/SKILL.md @@ -1,7 +1,7 @@ --- name: himalaya description: "Himalaya CLI: IMAP/SMTP email from terminal." -version: 1.1.0 +version: 2.0.0 author: community license: MIT platforms: [linux, macos, windows] @@ -48,13 +48,20 @@ cargo install himalaya --locked ## Configuration Setup -Run the interactive wizard to set up an account: +Run the interactive wizard (bare `himalaya` — no subcommand) to set up an account: ```bash -himalaya account configure +himalaya ``` -Or create `~/.config/himalaya/config.toml` manually: +The wizard tests IMAP and SMTP connectivity, then prints a ready-to-save +TOML config to stdout. Redirect it directly: + +```bash +himalaya > ~/.config/himalaya/config.toml +``` + +Or create `~/.config/himalaya/config.toml` manually using per-backend tables: ```toml [accounts.personal] @@ -62,56 +69,41 @@ email = "you@example.com" display-name = "Your Name" default = true -backend.type = "imap" -backend.host = "imap.example.com" -backend.port = 993 -backend.encryption.type = "tls" -backend.login = "you@example.com" -backend.auth.type = "password" -backend.auth.cmd = "pass show email/imap" # or use keyring - -message.send.backend.type = "smtp" -message.send.backend.host = "smtp.example.com" -message.send.backend.port = 587 -message.send.backend.encryption.type = "start-tls" -message.send.backend.login = "you@example.com" -message.send.backend.auth.type = "password" -message.send.backend.auth.cmd = "pass show email/smtp" - -# Folder aliases (himalaya v1.2.0+ syntax). Required whenever the -# server's folder names don't match himalaya's canonical names -# (inbox/sent/drafts/trash). Gmail is the common case — see -# `references/configuration.md` for the `[Gmail]/Sent Mail` mapping. -folder.aliases.inbox = "INBOX" -folder.aliases.sent = "Sent" -folder.aliases.drafts = "Drafts" -folder.aliases.trash = "Trash" +imap.server = "imap.example.com:993" +imap.sasl.plain.username = "you@example.com" +imap.sasl.plain.password.raw = "your-password" + +smtp.server = "smtp.example.com:587" +smtp.starttls = true +smtp.sasl.plain.username = "you@example.com" +smtp.sasl.plain.password.raw = "your-password" + +mailbox.alias.inbox = "INBOX" +mailbox.alias.sent = "Sent" +mailbox.alias.drafts = "Drafts" +mailbox.alias.trash = "Trash" ``` -> **Heads up on the alias syntax.** Pre-v1.2.0 docs used a -> `[accounts.NAME.folder.alias]` sub-section (singular `alias`). -> v1.2.0 silently ignores that form — TOML parses fine, but the -> alias resolver never reads it, so every lookup falls through to -> the canonical name. On Gmail this means save-to-Sent fails *after* -> SMTP delivery succeeds, and `himalaya message send` exits non-zero. -> Any caller (agent, script, user) that retries on that exit code -> will re-run the entire send — including SMTP — producing duplicate -> emails to recipients. Always use `folder.aliases.X` (plural, dotted -> keys, directly under `[accounts.NAME]`). +> **Heads up on the alias syntax.** Himalaya v2.0.0 renamed +> `folder.aliases.*` to `mailbox.alias.*`. The old dotted keys are +> silently ignored — TOML parses fine, but the alias resolver never reads +> them. On Gmail this means save-to-Sent fails *after* SMTP delivery +> succeeds, and `himalaya message send` exits non-zero. Always use +> `mailbox.alias.X` in v2.0.0+. ## Hermes Integration Notes - **Reading, listing, searching, moving, deleting** all work directly through the terminal tool - **Composing/replying/forwarding** — piped input (`cat << EOF | himalaya template send`) is recommended for reliability. Interactive `$EDITOR` mode works with `pty=true` + background + process tool, but requires knowing the editor and its commands -- Use `--output json` for structured output that's easier to parse programmatically -- The `himalaya account configure` wizard requires interactive input — use PTY mode: `terminal(command="himalaya account configure", pty=true)` +- Use `--json` before the subcommand for structured output that's easier to parse programmatically (e.g. `himalaya --json envelope list`) +- The bare `himalaya` wizard requires interactive input — use PTY mode: `terminal(command="himalaya", pty=true)` ## Common Operations -### List Folders +### List Mailboxes ```bash -himalaya folder list +himalaya mailbox list ``` ### List Emails @@ -275,11 +267,10 @@ himalaya attachment download 42 --downloads-dir ~/Downloads ## Output Formats -Most commands support `--output` for structured output: +Most commands support `--json` (before the subcommand) for structured output: ```bash -himalaya envelope list --output json -himalaya envelope list --output plain +himalaya --json envelope list ``` ## Debugging diff --git a/skills/email/himalaya/references/configuration.md b/skills/email/himalaya/references/configuration.md index 5ccba6cbc321..adf5161836e8 100644 --- a/skills/email/himalaya/references/configuration.md +++ b/skills/email/himalaya/references/configuration.md @@ -11,29 +11,22 @@ display-name = "Your Name" default = true # IMAP backend for reading emails -backend.type = "imap" -backend.host = "imap.example.com" -backend.port = 993 -backend.encryption.type = "tls" -backend.login = "user@example.com" -backend.auth.type = "password" -backend.auth.raw = "your-password" +imap.server = "imap.example.com:993" +imap.sasl.plain.username = "user@example.com" +imap.sasl.plain.password.raw = "your-password" # SMTP backend for sending emails -message.send.backend.type = "smtp" -message.send.backend.host = "smtp.example.com" -message.send.backend.port = 587 -message.send.backend.encryption.type = "start-tls" -message.send.backend.login = "user@example.com" -message.send.backend.auth.type = "password" -message.send.backend.auth.raw = "your-password" - -# Folder aliases — required whenever server folder names differ -# from himalaya's canonical names. See "Folder Aliases" below. -folder.aliases.inbox = "INBOX" -folder.aliases.sent = "Sent" -folder.aliases.drafts = "Drafts" -folder.aliases.trash = "Trash" +smtp.server = "smtp.example.com:587" +smtp.starttls = true +smtp.sasl.plain.username = "user@example.com" +smtp.sasl.plain.password.raw = "your-password" + +# Mailbox aliases — required whenever server folder names differ +# from himalaya's canonical names. See "Mailbox Aliases" below. +mailbox.alias.inbox = "INBOX" +mailbox.alias.sent = "Sent" +mailbox.alias.drafts = "Drafts" +mailbox.alias.trash = "Trash" ``` ## Password Options @@ -41,23 +34,18 @@ folder.aliases.trash = "Trash" ### Raw password (testing only, not recommended) ```toml -backend.auth.raw = "your-password" +imap.sasl.plain.password.raw = "your-password" +# smtp.sasl.plain.password.raw = "your-password" ``` ### Password from command (recommended) ```toml -backend.auth.cmd = "pass show email/imap" -# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w" +imap.sasl.plain.password.cmd = "pass show email/imap" +# imap.sasl.plain.password.cmd = "security find-generic-password -a user@example.com -s imap -w" ``` -### System keyring (requires keyring feature) - -```toml -backend.auth.keyring = "imap-example" -``` - -Then run `himalaya account configure ` to store the password. +Then run `himalaya` to set up an account (the wizard prints a ready-to-save TOML config). ## Gmail Configuration @@ -67,31 +55,24 @@ email = "you@gmail.com" display-name = "Your Name" default = true -backend.type = "imap" -backend.host = "imap.gmail.com" -backend.port = 993 -backend.encryption.type = "tls" -backend.login = "you@gmail.com" -backend.auth.type = "password" -backend.auth.cmd = "pass show google/app-password" - -message.send.backend.type = "smtp" -message.send.backend.host = "smtp.gmail.com" -message.send.backend.port = 587 -message.send.backend.encryption.type = "start-tls" -message.send.backend.login = "you@gmail.com" -message.send.backend.auth.type = "password" -message.send.backend.auth.cmd = "pass show google/app-password" +imap.server = "imap.gmail.com:993" +imap.sasl.plain.username = "you@gmail.com" +imap.sasl.plain.password.raw = "app-password" + +smtp.server = "smtp.gmail.com:587" +smtp.starttls = true +smtp.sasl.plain.username = "you@gmail.com" +smtp.sasl.plain.password.raw = "app-password" # Gmail folder mapping. Without these, save-to-Sent fails after # SMTP delivery succeeds (Gmail's Sent folder is `[Gmail]/Sent Mail`, # not `Sent`), and `himalaya message send` exits non-zero. Any # caller that retries on that error will re-run SMTP — duplicate # emails to recipients. Always include this block for Gmail. -folder.aliases.inbox = "INBOX" -folder.aliases.sent = "[Gmail]/Sent Mail" -folder.aliases.drafts = "[Gmail]/Drafts" -folder.aliases.trash = "[Gmail]/Trash" +mailbox.alias.inbox = "INBOX" +mailbox.alias.sent = "[Gmail]/Sent Mail" +mailbox.alias.drafts = "[Gmail]/Drafts" +mailbox.alias.trash = "[Gmail]/Trash" ``` **Note:** Gmail requires an App Password if 2FA is enabled. @@ -103,62 +84,47 @@ folder.aliases.trash = "[Gmail]/Trash" email = "you@icloud.com" display-name = "Your Name" -backend.type = "imap" -backend.host = "imap.mail.me.com" -backend.port = 993 -backend.encryption.type = "tls" -backend.login = "you@icloud.com" -backend.auth.type = "password" -backend.auth.cmd = "pass show icloud/app-password" - -message.send.backend.type = "smtp" -message.send.backend.host = "smtp.mail.me.com" -message.send.backend.port = 587 -message.send.backend.encryption.type = "start-tls" -message.send.backend.login = "you@icloud.com" -message.send.backend.auth.type = "password" -message.send.backend.auth.cmd = "pass show icloud/app-password" +imap.server = "imap.mail.me.com:993" +imap.sasl.plain.username = "you@icloud.com" +imap.sasl.plain.password.raw = "app-password" + +smtp.server = "smtp.mail.me.com:587" +smtp.starttls = true +smtp.sasl.plain.username = "you@icloud.com" +smtp.sasl.plain.password.raw = "app-password" ``` **Note:** Generate an app-specific password at appleid.apple.com -## Folder Aliases +## Mailbox Aliases -Map himalaya's canonical folder names (`inbox`, `sent`, `drafts`, -`trash`) to whatever the server actually calls them. Use the -v1.2.0 `folder.aliases.X` syntax (plural, dotted keys, directly -under `[accounts.NAME]`): +Map himalaya's canonical mailbox names (`inbox`, `sent`, `drafts`, +`trash`) to whatever the server actually calls them: ```toml [accounts.default] # ... other account config ... -folder.aliases.inbox = "INBOX" -folder.aliases.sent = "Sent" -folder.aliases.drafts = "Drafts" -folder.aliases.trash = "Trash" +mailbox.alias.inbox = "INBOX" +mailbox.alias.sent = "Sent" +mailbox.alias.drafts = "Drafts" +mailbox.alias.trash = "Trash" ``` -The equivalent TOML sub-section form also works in v1.2.0: +The equivalent TOML sub-section form also works: ```toml -[accounts.default.folder.aliases] +[accounts.default.mailbox.aliases] inbox = "INBOX" sent = "Sent" drafts = "Drafts" trash = "Trash" ``` -> **Don't use the singular `alias` form.** Pre-v1.2.0 docs showed -> `[accounts.NAME.folder.alias]` (singular). v1.2.0 silently -> ignores that sub-section — TOML parses without error, but the -> alias resolver never reads it. Every lookup then falls through -> to the canonical name. On Gmail (where `sent` is actually -> `[Gmail]/Sent Mail`) this means save-to-Sent fails *after* SMTP -> delivery succeeds, and `himalaya message send` exits non-zero. -> Any caller (agent, script, user) that retries on that error -> code will re-run the send — including SMTP — producing duplicate -> emails to recipients. Always use `folder.aliases.X` (plural). +> **Note on v2.0.0 change.** Himalaya v2.0.0 renamed `folder.aliases.*` +> to `mailbox.alias.*`. If you are upgrading from v1.x, update your +> config accordingly — the old `folder.aliases.*` keys are ignored by +> v2.0.0. ## Multiple Accounts @@ -184,21 +150,19 @@ himalaya --account work envelope list ```toml [accounts.local] email = "user@example.com" - -backend.type = "notmuch" -backend.db-path = "~/.mail/.notmuch" +# Config structure for notmuch differs — see himalaya docs. ``` ## OAuth2 Authentication (for providers that support it) ```toml -backend.auth.type = "oauth2" -backend.auth.client-id = "your-client-id" -backend.auth.client-secret.cmd = "pass show oauth/client-secret" -backend.auth.access-token.cmd = "pass show oauth/access-token" -backend.auth.refresh-token.cmd = "pass show oauth/refresh-token" -backend.auth.auth-url = "https://provider.com/oauth/authorize" -backend.auth.token-url = "https://provider.com/oauth/token" +# IMAP SASL OAuth2 +imap.sasl.oauth2.client-id = "your-client-id" +imap.sasl.oauth2.client-secret.cmd = "pass show oauth/client-secret" +imap.sasl.oauth2.access-token.cmd = "pass show oauth/access-token" +imap.sasl.oauth2.refresh-token.cmd = "pass show oauth/refresh-token" +imap.sasl.oauth2.auth-url = "https://provider.com/oauth/authorize" +imap.sasl.oauth2.token-url = "https://provider.com/oauth/token" ``` ## Additional Options From d745e908fc827761ba40675ed0867e17c58a3dcc Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 27 Jul 2026 16:19:32 -0300 Subject: [PATCH 2/3] fix: fire transform_tool_result for agent-runtime tools (#72836) --- agent/tool_executor.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 74092b90ee91..bce76ee038b8 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1643,6 +1643,37 @@ def _execute(next_args: dict) -> Any: duration_ms=int(tool_duration * 1000), middleware_trace=list(middleware_trace), ) + # Agent-runtime tools also never reach handle_function_call, which is + # the sole call site of transform_tool_result for registry-dispatched + # tools. Fire it here for the same set of inline-dispatched tools so + # the hook applies to every tool as documented. + if not _execution_blocked and agent_runtime_owns_post_tool_hook(agent, function_name): + try: + from hermes_cli.plugins import has_hook, invoke_hook + if has_hook("transform_tool_result"): + from model_tools import _tool_result_observer_fields + _tr_status, _tr_err_type, _tr_err_msg = _tool_result_observer_fields(function_result) + _tr_results = invoke_hook( + "transform_tool_result", + tool_name=function_name, + args=function_args, + result=function_result, + task_id=effective_task_id or "", + session_id=agent.session_id or "", + tool_call_id=getattr(tool_call, "id", "") or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + duration_ms=int(tool_duration * 1000), + status=_tr_status, + error_type=_tr_err_type, + error_message=_tr_err_msg, + ) + for _tr_hook_result in _tr_results: + if isinstance(_tr_hook_result, str): + function_result = _tr_hook_result + break + except Exception as _tr_hook_err: + logger.debug("transform_tool_result hook error: %s", _tr_hook_err) if not _execution_blocked: function_result = agent._append_guardrail_observation( function_name, From 714d47b733d2518302ade617d7b39c1d50b9a7c7 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 27 Jul 2026 16:23:11 -0300 Subject: [PATCH 3/3] fix(mcp): reload args on config.yaml changes (#72839) --- tests/tools/test_mcp_tool.py | 23 +++++++++++++ tools/mcp_tool.py | 64 ++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 7e5b40c1cc37..c90e638fec67 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -4409,6 +4409,29 @@ async def fake_register(name, cfg): # Tests for parallel tool call support (port from openai/codex#17667) # --------------------------------------------------------------------------- + def test_skips_already_connecting_servers(self): + """A server name already in _server_connecting is not re-spawned. + + Regression for #72818: concurrent register_mcp_servers() calls can + both see the name absent from _servers but neither checks + _server_connecting, resulting in duplicate stdio processes. + """ + from tools.mcp_tool import register_mcp_servers, _server_connecting, _lock + + with _lock: + _server_connecting.add("connecting") + try: + with patch("tools.mcp_tool._MCP_AVAILABLE", True), \ + patch("tools.mcp_tool._existing_tool_names", return_value=[]), \ + patch("tools.mcp_tool._run_on_mcp_loop") as mock_run: + result = register_mcp_servers({"connecting": {"command": "test"}}) + assert result == [] + mock_run.assert_not_called() + finally: + with _lock: + _server_connecting.discard("connecting") + + class TestMcpParallelToolCalls: """Tests for the supports_parallel_tool_calls config option.""" diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 28dd5e917419..20ac556734b9 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1811,6 +1811,30 @@ def _invoke_consent() -> str: # Server task -- each MCP server lives in one long-lived asyncio Task # --------------------------------------------------------------------------- + +def _compute_config_digest(config: dict) -> str: + """Compute a stable hash of config fields that affect server behavior. + + Hashing only the fields that change the spawned subprocess or transport + session -- excluding meta-keys like enabled, supports_parallel_tool_calls, + and tools (those are consumed by Hermes itself, not by the server). + """ + import hashlib + relevant: dict = {} + for k in ( + "command", "args", "env", + "url", "headers", "transport", + "auth", "timeout", "connect_timeout", "keepalive_interval", + "idle_timeout_seconds", "max_lifetime_seconds", + "ssl_verify", "skip_preflight", + "sampling", "elicitation", + ): + if k in config: + relevant[k] = config[k] + raw = json.dumps(relevant, sort_keys=True, default=str) + return hashlib.md5(raw.encode()).hexdigest() + + class MCPServerTask: """Manages a single MCP server connection in a dedicated asyncio Task. @@ -1824,7 +1848,7 @@ class MCPServerTask: __slots__ = ( "name", "session", "tool_timeout", "_task", "_ready", "_shutdown_event", "_reconnect_event", - "_tools", "_error", "_config", + "_tools", "_error", "_config", "_config_digest", "_sampling", "_elicitation", "_registered_tool_names", "_auth_type", "_refresh_lock", "_rpc_lock", "_pending_refresh_tasks", @@ -3022,6 +3046,7 @@ async def run(self, config: dict): connection drops unexpectedly (unless shutdown was requested). """ self._config = config + self._config_digest = _compute_config_digest(config) self.tool_timeout = config.get("timeout", _DEFAULT_TOOL_TIMEOUT) self._auth_type = (config.get("auth") or "").lower().strip() self._idle_timeout_seconds = _get_lifecycle_seconds(config, "idle_timeout_seconds") @@ -5654,10 +5679,43 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]: # Only attempt servers that aren't already connected and are enabled # (enabled: false skips the server entirely without removing its config) with _lock: + # --- Detect servers whose config changed --- + # Remove old server tasks whose config no longer matches so the + # fresh config is picked up below as a "new" server to connect. + changed_names: list[str] = [] + for name, cfg in servers.items(): + srv = _servers.get(name) + if srv is None: + continue + new_digest = _compute_config_digest(cfg) + if getattr(srv, "_config_digest", None) is not None and srv._config_digest != new_digest: + changed_names.append(name) + for name in changed_names: + srv = _servers.pop(name) + logger.info( + "MCP server '%s': config changed, restarting with new config", + name, + ) + # Fire-and-forget shutdown — the old transport / subprocess + # will be reaped by the orphan-cleanup in _run_stdio. + srv._shutdown_event.set() + srv._reconnect_event.set() + if srv._task and not srv._task.done(): + srv._task.cancel() + # Clear connect failure state so the new attempt isn't blocked + _server_connect_retry_after.pop(name, None) + _server_connect_failures.pop(name, None) + _server_connecting.discard(name) + _server_connect_errors.pop(name, None) + # Deregister tools to avoid stale tool entries + if callable(getattr(srv, "_deregister_tools", None)): + srv._deregister_tools() + new_servers = { k: v for k, v in servers.items() if k not in _servers + and k not in _server_connecting and _parse_boolish(v.get("enabled", True), default=True) # Skip a server still serving its post-failure backoff. Without # this, a server that fails to connect (and is therefore never @@ -5789,7 +5847,9 @@ def discover_mcp_tools() -> List[str]: new_server_names = [ name for name, cfg in servers.items() - if name not in _servers and _parse_boolish(cfg.get("enabled", True), default=True) + if name not in _servers + and name not in _server_connecting + and _parse_boolish(cfg.get("enabled", True), default=True) ] tool_names = register_mcp_servers(servers)