Sync current Nous Hermes main into Ace patches - #43
Merged
Merged
Conversation
Completes the bug class from NousResearch#83906 — the same blocking fsync-on-event-loop pattern existed in two more async gateway paths: - slash_commands.py _handle_restart_command: two atomic_json_write calls for .restart_notify.json and .restart_last_processed.json were blocking on fsync inside an async function. Now offloaded via asyncio.to_thread. - run.py _clear_restart_failure_count: called from _handle_message_with_agent (async, per-turn path) after a successful agent turn. Made the method async and offloaded the atomic_json_write call via asyncio.to_thread. Caller updated to await. Shutdown-path calls in _stop_impl_body (_increment_restart_failure_counts, planned restart notification marker) are intentionally left synchronous — the event loop is draining/stopping and offloading adds complexity for no benefit.
…s-index workflows Three separate reds on main. Two are fixed here; the third needs no code. 1. tests/gateway/test_multiplex_busy_input_mode.py (blocks every merge) Fails "Python tests / Run tests slice 5/12" and therefore "All required checks pass". Semantic merge conflict between two PRs merged ~1h apart: a31be48 fix(gateway): respect routed profile busy modes (added the test) c8f235a feat(gateway): allow selective multiplex profile serving (added the gate) c8f235a taught _profile_name_for_source to reject a route whose target profile is not in the served set (profiles_to_serve). Each PR was green on its own base; neither ran against the other's merge result. The test asserts a route to profile "research" resolves to that profile's busy mode, but never patches profiles_to_serve — so it reads the runner's REAL on-disk profiles. "research" is not among them, the route is rejected before the busy-mode snapshot is consulted, and the assertion gets the gateway default: WARNING gateway.run: Rejecting profile route 'research-chat': target profile 'research' is not served AssertionError: assert 'interrupt' == 'steer' Patch profiles_to_serve for the assertion — the same seam every sibling test in tests/gateway/test_profile_resolution.py already patches (test_route_inside_allowlist_resolves, test_route_outside_allowlist_rejects). This also removes an ambient-state dependency: the test previously passed or failed based on which profiles happened to exist on the machine running it. Verified passing under an empty HERMES_HOME. Test-only. The serving gate from c8f235a is correct and left intact. 2. Skills-index workflows: local action used without actions/checkout check-freshness has failed on all 12 of its last 12 scheduled runs: ##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '.../.github/actions/get-app-token'. Did you forget to run actions/checkout before running your local action? ./.github/actions/get-app-token is a LOCAL composite action and cannot resolve without the repo on disk. skills-index-freshness.yml had no checkout step at all. The step is gated on `status != 'ok'`, so the watchdog broke exactly when it was supposed to file its issue — the live index is currently 521.4h stale (limit 26h) and nobody was told. An audit of all workflows for this bug class found one more instance: skills-index.yml's `trigger-deploy` job, which re-triggers the docs deploy so a refreshed index reaches the live site. Its sibling `build-index` job checks out; this one did not. That is plausibly why the index went stale in the first place. Both are fixed; the audit now reports zero remaining jobs that use a local action without a prior checkout. Pinned to the same actions/checkout SHA used by the other 35 call sites. 3. "Publish inline E2E evidence" — no fix needed Failed once at 13:33Z on a transient TLS error reaching api.github.com ("certificate is not valid for any names") while installing a gh extension. The last 25 runs of that workflow are 25/25 success. Infra blip, not a code defect.
The hand-off script's WinForms window was a 720x420 dashboard: streaming log box, wide marquee, warning label. Updating is a wait, not a dashboard -- it is now the same shape as the other update surfaces (NousResearch#75895): a fixed 280x320 panel, marquee loader, one title, one static line, following the OS light/dark theme (charcoal #232323 seeds, never brand blue). Failure gets a terse finale instead of a wall of log: 'Failed to update' + 'Run "hermes debug share" in a terminal to send a report' + Close (held max 5 minutes, then the relaunched Desktop re-surfaces the result banner as before). The result-json message points at debug share too. With nothing streamed to the window, the per-line stdout pump is gone: Invoke-HermesStep drains both pipes async (no deadlock on chatty children, no frozen marquee on quiet ones) and writes full output to the hand-off log afterwards, where hermes debug share picks it up.
scripts/desktop-update.ps1 moves to scripts/desktop-update/windows.ps1 (a compat forwarder stays at the old path for one asar/checkout skew cycle) and gains the shim: scripts/desktop-update/ui.html rendered in a chromeless Edge app window, fed done|error over a loopback /progress endpoint. The page is NousResearch#75895's hand-off screen ported verbatim (Fourier Flow loader, one title, one line, OS light/dark, charcoal dark seeds); failure is the terse card pointing at hermes debug share. The WinForms card stays as the no-Edge fallback, same shape. Salvaged from the web-shell spike: TcpListener runspace server, Edge --app spawn with throwaway profile, degradation ladder, -SelfTestUi. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
scripts/desktop-update/posix.sh is the mac/linux twin of windows.ps1: the Desktop spawns it detached and QUITS; it waits the app out, runs plain hermes update (retry-once across the update boundary, truthful desktop-rebuild completion), swaps/relaunches the .app bundle (mac) or the release/*-unpacked binary when its sandbox helper is launchable (linux), writes .hermes-update-result.json, and drives the same shim. Repo-owned, so every update refreshes the code that drives the next one. resolvePosixScriptHandoff mirrors the Windows resolver (with the flat-path fallback covering the scripts/ reorg skew).
applyUpdatesPosixInApp is gone: mac/linux Update now quits into the detached posix orchestrator, same shape as Windows. Deletes everything the in-app path dragged into main.ts -- runStreamedUpdate, the rebuild retry, the relaunch-outcome matrix (update-relaunch.ts/update-rebuild.ts and tests), shellQuote, resolveHermesCliBinary -- and with the app dead before the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (NousResearch#37532) is structurally unnecessary on the desktop path.
scripts/desktop-update/repro.sh drives the real code paths against a disposable HERMES_HOME under /tmp: shim/shim-fail (UI dry runs), fresh (literal install.sh), behind N (rewound checkout driven forward by the orchestrator), error (broken venv -> abort + result file). Exposed as npm run update:shim / update:shim:fail / update:repro:* from apps/desktop.
…escaping Address helix4u's review: - finish() now delivers the outcome BEFORE publishing it: mac bundle swap and the linux relaunch gate run first, then the result file, marker removal, and the shim event -- the app launch itself goes last so it can't race the result write. A gated/skewed linux install (AppImage/ deb/rpm, broken sandbox helper) surfaces its message in the result file AND holds the shim window open with it instead of closing on a false 'Opening Hermes...'. - mac swap is transactional with a checked rollback; a failed install restores the previous bundle and the result says so (exit 7 when even rollback fails). Failed 'open' rewrites the result truthfully. - linux gate is an exact port of the deleted update-relaunch.ts logic: anchored path-segment match on <root>/apps/desktop/release/linux-unpacked, chrome-sandbox absent = namespace build = fine, present = root+setuid required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox among replayed args, or the Desktop vouching) instead of the invented HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv live in updater-process.ts again; the Desktop passes filtered launch args (after --) and --relaunch-cwd so a deep-link or --no-sandbox launch survives the update. - result/status JSON strings are escaped (git permits '"' in branch names) and the result write is atomic (tmp + rename). - coverage: resolvePosixScriptHandoff + ported helpers in updater-process.test.ts (19 pass); repro.sh gate / npm run update:repro:gate asserts the whole gate matrix and round-trips a hostile branch name through the result JSON.
…repro The posix orchestrator inherited the Desktop's cwd, and parts of the update pipeline resolve the tree they mutate from the working directory -- the sandboxed behind-repro caught it updating the DEVELOPER'S primary checkout (cwd at spawn time) while reporting success against the sandbox. cd "$INSTALL_ROOT" before running hermes update, matching the cwd:updateRoot contract of the deleted in-app path. Verified: rerun leaves the outside checkout untouched (reflog clean). repro.sh fresh used a --no-interactive flag install.sh doesn't have; non-TTY stdin (</dev/null) + --skip-setup is the real non-interactive contract.
…hestrators gille's round-2 review: the terminal lifecycle claimed outcomes the launch hadn't delivered yet. - posix finish() reorders: outcome -> durable result+marker -> LAUNCH WITH ACCEPTANCE -> terminal event. mac acceptance is open's exit code (launchd rejects broken bundles loudly); linux verifies the setsid child is still alive 1.5s after spawn, so an instant exec failure downgrades to a held 'manual' state + truthful result instead of a vanished 'done'. Gated skew/manual outcomes publish a real 'manual' event (new third shim state -- still zero logic in the page). - Renderer-free linux recovery: when no chromium-family browser exists, manual/error outcomes fire notify-send/zenity/kdialog best-effort so a gated non-relaunch is never a silent disappearance. - windows.ps1 mirrors the contract: Start-DesktopRelaunch returns verified acceptance (WMI pid alive / fallback process alive; dying before the window appears counts as failure), and the finally block downgrades to Show-ManualFinale + rewritten result when the launch didn't land. Error path still relaunches after showing itself. - repro.sh launch / npm run update:repro:launch: real-orchestrator matrix for instant-exit relaunch downgrade and skew-message surfacing. - posix.sh cds into the install root before hermes update (found by the sandboxed behind-repro: parts of the update resolve the mutated tree from cwd, which is the Desktop's cwd -- it updated the DEVELOPER'S checkout while reporting success against the sandbox).
…covery surface gille's round 3: - cd into the install root FAILS CLOSED (set -u without set -e let a failed cd continue hermes update in the caller's tree -- the exact wrong-tree class the correction exists to kill). Honest result, exit 3. - A supplied mac relaunch target that is missing is a REJECTED launch -> manual downgrade; the launch matrix asserts the downgrade instead of codifying the old false success. A mac swap-failure DONE_NOTE now still relaunches the kept/rolled-back bundle before publishing manual. - notify_fallback: every rung falls through on EXECUTION failure (a notify-send that can't reach D-Bus no longer eats the message), mac gets osascript (present on every macOS -- Safari-only machines have no chromium shim), and the no-surface terminal case is an explicit logged contract: the result file carries the outcome to the next boot. - update:repro:fresh passes --non-interactive explicitly (prompt_yes_no falls back to /dev/tty, so </dev/null was not equivalent).
Round 4 of helix4u's review — the durable fallback is now real: - Result protocol gains `manual`: an ok result the user still must act on (reopen the app, reinstall the GUI package, fix the sandbox helper). Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop consumer surfaces manual results in a real dialog on next boot instead of a log line — the browserless-Linux disappearance now ends at a visible dialog, worst case one boot later. Older result files without the field parse as manual:false (covered). - notify ladder verifies EXECUTION, not existence: zenity/kdialog must survive their first second (an instant death means no display and falls through); the no-surface case is an explicit best-effort contract whose guaranteed channel is the result dialog. - mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no longer swallowed (`|| true` dropped): the durable message carries both facts. - launch/gate matrices assert `manual` in the result JSON; consumer round-trip tested in handoff-result.test.ts.
Fix NousResearch#78906 当部署同时启用 basic 密码 provider 与一个 OAuth/OIDC session provider 时, list_session_providers() 会把密码 provider 也计入 "exactly one candidate" 判断(密码 provider 虽是 session provider,但下一行就会因 supports_password 被原生 OAuth broker 流程拒绝),导致 len == 2、自动选择被跳过,桌面端 空 provider 登录返回 404 "Unknown provider: ''"。 修复:自动选择只在可 broker 的 provider(supports_session 且非 supports_password)中计数,与 /api/status 的 native_pkce 能力宣告使用同一 "brokerable" 定义;当没有任何可 broker provider 时保留原有选择逻辑, 让显式的 400 错误继续解释密码 provider 不支持原生 OAuth。 新增回归测试:basic+OIDC 并存时自动选中 OIDC、单 OAuth provider 自动 选中、多 OAuth provider 歧义 404、纯密码部署保留 400。
A manual:true hand-off result is the durable action-required channel: on a browserless Linux box with no working notifier, the boot dialog is the first and only place the message ever surfaces. The 30-minute freshness gate discarded it if the user reopened Hermes later, stranding exactly the machine the channel exists to serve. Parse before the age check and skip the window for manual results; the file is still unlinked before any age check, so it's surfaced at most once. Ordinary results still expire. Regression: a stale ordinary result is discarded (and consumed) while a stale manual result is still returned once.
…owngrading The Browser Use CLI became the default browser backend, but nothing provisioned it: users without uv/uvx (field report from DongyangHe on macOS) silently fell back to the built-in browser tools with no notice. - install_cli() in tools/browser_use_cli.py: uv tool install browser-use via the managed uv (bootstrapped on demand), linked into $HERMES_HOME/bin (UV_TOOL_BIN_DIR) - _find_cli() now also probes $HERMES_HOME/bin for browser-use/uvx — Hermes' managed uv is not on the user's PATH - hermes tools post_setup actually installs (Camofox standard) instead of printing instructions - install.sh / install.ps1 provision the CLI at install time (best-effort, non-fatal, honors --skip-browser) - CLI startup shows a one-line notice (24h rate-limited) when the default backend downgraded to the built-in tools
… fallback - install.ps1 must stay pure ASCII (PowerShell 5.1 ANSI code-page decoding, NousResearch#66994/NousResearch#67000): em-dash -> '--' - tests/test_managed_runtime_resolution.py: install_cli()'s shutil.which('uv') is a reviewed fallback AFTER ensure_uv() misses
…-f2b15435 feat(browser): auto-install the Browser Use CLI instead of silently downgrading
… press (NousResearch#83677) * fix(relay): stop sibling gateways answering another instance's button press A Discord button press arrives on the passthrough plane, and the connector fans a passthrough forward out to EVERY live gateway session of the tenant (relayServer.routeBusMessage delivers `passthrough` via sessionsByTenant), unlike a message, which it narrows to the admitted instance set. The prompt went out from exactly one instance and _pending_prompts is process-local, so every sibling gateway saw an answer for a prompt it never minted, could not tell that from its own prompt expiring, and fell through to chat dispatch -- where the option-shaped text ("/c1") is not a real command and run.py replied "Unknown command `/c1`". One copy per sibling, under the single real ack. Prompt ids are now minted as `<per-process nonce>.<8 hex>`, so an answer can be attributed to the process that minted it. A prompt answer is always consumed, never re-dispatched as chat: a sibling's prompt and a repeat answer are both dropped silently, and an expired prompt of our own gets a short "no longer waiting" notice from the owning gateway only. Ids stay inside the connector codec's contract ([A-Za-z0-9_.-], <=32 chars, 64-byte callback budget -- verified against promptCodec.ts: 52 bytes worst case with a full-length option id). An id with no nonce segment (a prompt in flight across an in-place upgrade) is still treated as ours. Tests: 4 added, each verified to fail without the fix. Full relay suite green (160 tests). * style(tests): ruff-format the added relay prompt tests
…ousResearch#84074) * feat(relay): ambient token endpoint mode for gateway.idp.token_url When gateway.idp.token_url is configured WITHOUT client_id/client_secret, treat the URL as a metadata-server-style ambient credential endpoint: plain GET, response body is the token (raw JWT or {"access_token": ...} JSON envelope). Covers workload-identity proxies such as Domino's $DOMINO_API_PROXY/access-token, which mint short-lived user-scoped OIDC tokens with no client registration. Previously this configuration was a hard error (client_id/client_secret missing), so no working deployment changes behaviour: creds present keeps the OAuth2 client_credentials POST, no token_url keeps Nous Portal. The misconfig error now self-diagnoses (names the ambient fallback and how to select the client_credentials grant instead). * fix(relay): reject short plain-text bodies in ambient token shape gate Review finding: the shape gate accepted any base64url-alphabet word, so an IdP answering the ambient GET with a terse error body ('unauthorized', 'error', 'null') had that word returned as a bearer token instead of the fail-closed misconfiguration error. Tighten the gate to JWT-like dotted tokens (3+ segments) or long opaque tokens (>= 32 chars); short bare words now raise the self-diagnosing ambient error. * fix(relay): partial IdP client credentials keep the loud error, never select ambient GET The ambient-endpoint dispatch used 'not client_id or not client_secret', so configuring exactly one credential (a mistyped client_credentials setup) silently issued a GET at the IdP token endpoint and then raised 'no client_id/client_secret configured' — factually wrong for that operator, and a stray request the old hard error never made. Ambient mode now requires NEITHER credential; a partial pair raises immediately, names the missing key, and issues no HTTP request (tests assert urlopen is never called). Docstring and relay.md now say 'neither' instead of 'without'. * fix(relay): ambient JSON envelope requires a string access_token, no coercion Review finding (P2): the JSON-envelope branch accepted any truthy access_token via str() coercion — a number became '12345…', a boolean became 'True', an object became its Python repr — bypassing the fail- closed contract and deferring the failure to the connector, where it hides the real endpoint problem. The envelope value must now be a non-empty string, the same contract the client_credentials path enforces on its token response. Deliberately NO shape gate on envelope values: an envelope is an intentional token response (mode-1 symmetry), and opaque tokens may use the standard-base64 alphabet the raw-body gate rejects. Mutation check: reverting the branch to str() coercion sends the 3 coercion tests red (3 failed, 15 passed). --------- Co-authored-by: Ben Barclay <ben@nousresearch.com>
…ndow Detached update hand-off on every OS: quit → hermes update → reopen, with one dumb shim window
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…stead of relying on Fly autostop (NousResearch#84295) Fly Proxy autostop judges idle exclusively on inbound proxied connections. It cannot see an in-flight agent turn (outbound-only LLM traffic), and since Fly's mid-2026 proxy change an open outbound socket (the relay WS) no longer holds a machine awake. With autostop:"suspend", Fly suspended machines while they were still processing long-running jobs, and could suspend before the gateway flipped the relay destination (the buffered-event black hole). The scale-to-zero watcher now owns the suspend: after the idle predicate holds (no running agents, no live background work, inbound-quiet) and the go_dormant() quiesce completes (relay drained + flipped), it POSTs /v1/apps/{app}/machines/{id}/suspend on the local /.fly/api flaps socket. Suspend is skipped when the quiesce fails or inbound lands mid-quiesce (flip-before-freeze), and off-Fly the step is a no-op (fail-awake). Pairs with the NAS change that provisions scale-to-zero machines with autostop:"off" (gateway-owned suspend); wake is unchanged (Fly-proxied wakeUrl poke + autostart).
…nel (NousResearch#84300) Cron jobs targeting a relay-fronted logical platform (e.g. Discord behind the relay connector) failed twice over: 1. Target resolution read only the legacy <PLATFORM>_HOME_CHANNEL env mirror. The canonical home_channel block that /sethome persists to config.yaml — the only store that exists in a relay-fronted deployment, where no native env var is exported — was never consulted, so deliver='discord' silently resolved to nothing and the job fell back to local-only. 2. Even with a resolved target, the delivery loop's native configured/enabled gate rejected the platform ('not configured/enabled') although resolve_delivery_transport had already produced a live relay transport fronting it. A relay-fronted platform is deliberately NOT natively enabled (its credential lives in the connector), so the native gate must not apply to a relay transport. Resolution now falls back from the env mirror to config.get_home_channel(platform) for both chat_id and thread_id (thread affinity only when the chat id came from the same config block), which also makes the 'all' routing token pick up relay-fronted platforms. The delivery gate honours a resolved relay transport, mirroring the enablement rule resolve_delivery_transport already applied; the standalone (no-relay) path keeps the historical gate byte-identical.
…-zero busy check (NousResearch#84327) _scale_to_zero_has_live_background_work() counted every task in _background_tasks — but _spawn_supervised parks all permanent watchers there (session-expiry, kanban, reconnect, the scale-to-zero watcher itself, ...). An armed gateway therefore considered itself busy forever and never went dormant or suspended. Verified live on staging (hermes-agent-stg-test-6698, 2026-08-12): armed at 05:25, fully idle for 25+ minutes, zero 'going dormant' lines. Fly's coarse proxy autostop used to mask the bug; once the gateway took ownership of the suspend (NousResearch#84295) it became load-bearing. _spawn_supervised now tags its tasks and the busy check skips them. Transient tasks (startup-resume events, delegation, tracked processes) still block suspend. New tests exercise the REAL _spawn_supervised path rather than a stubbed _background_tasks set — the stubbing is exactly why the earlier tests missed this (same call-site trap as the F25 arm bug); the key test fails on main and passes with the fix.
…on events (NousResearch#84318) The relay interactions passthrough lane (_discord_interaction_to_event) built its SessionSource with platform=Platform.RELAY and no delivered_via_upstream_relay marker — unlike the relay text lane (ws_transport._event_from_wire), which maps the connector's platform to the logical enum and stamps the authenticated-upstream flag. Consequences of the mismatch: - /sethome sent as a Discord slash command persisted the home channel under platforms.relay.home_channel (invisible to cron delivery, which looks up the logical platform) and mirrored it into the dead RELAY_HOME_CHANNEL env var — so cron jobs with deliver='discord' kept falling back to local-only even after the resolution/delivery fixes. The absent trust marker also meant via_relay=False, so the handler's 'Relay does not authenticate this logical home target' guard — designed to reject exactly this misfiled shape — never engaged. - Session keys forked: the connector binds the interaction's follow-up capability under buildSessionKey with platform 'discord' and chat_type 'group' (interactionSessionSource), while the gateway keyed the same interaction as relay/channel. - _capture_scope skipped recording _platform_by_chat (it ignores the generic 'relay'), losing the egress sender hint for the chat. Stamp Platform.DISCORD (the lane statically parses Discord interaction wire payloads), chat_type 'group' for guild channels (native-adapter and connector parity), and delivered_via_upstream_relay=True (parity with the text lane; set locally, never read off the wire). With this, slash-command /sethome files under platforms.discord and passes the via_relay guard legitimately, and cron delivery over relay works end to end with the NousResearch#84300 resolution fixes.
…apters + dashboard forwarder) (NousResearch#84339) * fix(gateway): pass live adapters to cron fire webhook's fire_due The Chronos fire webhook (/api/cron/fire) called provider.fire_due(job_id, adapters=None, loop=loop), so every externally-triggered fire delivered through the standalone path even with a live gateway in-process. E2EE platforms and relay-fronted logical platforms (whose ONLY send path is the live relay adapter — no native credential exists on the box) failed every external fire with "platform 'X' not configured/enabled", while the same job delivered fine under the built-in ticker (gateway/run.py passes runner.adapters). Resolve the runner (self.gateway_runner → app['gateway_runner'] → _gateway_runner_ref(), the same chain the drain check uses) and forward its adapters. No runner → adapters=None, preserving the historical standalone path byte-identically. Note: does not by itself fix Fly-hosted scale-to-zero deployments where NAS's callback lands on the DASHBOARD process (internal_port 9119) — _fire_cron_job_for_profile there has no gateway runner in-process. That topology needs a separate fire handoff (design pending). * fix(cron): dashboard forwards Chronos fires to the gateway (503 when unreachable) The dashboard's /api/cron/fire executed cron jobs in the DASHBOARD process via _fire_cron_job_for_profile with adapters=None. On hosted deployments (Fly proxy exposes only the dashboard's port) that made every managed-cron fire deliver through the standalone send path, which cannot serve relay-fronted logical platforms (their only sender is the live relay adapter in the gateway process — no native credential exists on the box) or E2EE rooms. It also ran the whole agent turn inside the dashboard: wrong process for memory/session ownership and fire-claim attribution. Restore the invariant that the GATEWAY owns cron execution: - Dashboard route: after verifying the NAS JWT and resolving the job's profile, FORWARD the fire to the gateway api_server's own /api/cron/fire on loopback, NAS bearer preserved (the gateway re-verifies the JWT — defense in depth, no new trust link), and pass the gateway's response through. Gateway unreachable → 503 so NAS retries per the Chronos contract (non-2xx = retryable; the store CAS de-dupes the eventual double fire). Deliberately NO local-execution fallback. - Endpoint resolution mirrors gateway/config.py's api_server load order per target profile (config.yaml extra.port → API_SERVER_PORT from process env or the profile's .env → 8642), with /p/<profile>/ prefix routing under multiplex. - docker/stage2-hook.sh: generate a strong API_SERVER_KEY into .env on first boot when absent (never overwrites an operator value), so the loopback api_server passes its startup guard on hosted images. The fire route itself is NAS-JWT-authed; the key gates the rest of the api_server surface. The listener binds 127.0.0.1 by default and the Fly service exposes only the dashboard port. - _fire_cron_job_for_profile kept but deprecated (late-binding seam compatibility); no route calls it. - docs/chronos-managed-cron-contract.md: document the two-hop inbound topology and the 503-retry semantics. Depends on the previous commit (fire webhook passes live adapters to fire_due) — together they make NAS→dashboard→gateway fires deliver over relay end to end. * fix(cron): read the profile api_server port via the canonical config loader CI guard test_config_read_guard flagged the new _gateway_fire_endpoint for a raw yaml.safe_load of the profile's config.yaml — the exact drift class the guard exists to kill (raw reads miss the managed-scope overlay, ${ENV_VAR} expansion, and root-model normalization). Read through load_config() under a HERMES_HOME override scoped to the target profile instead (the same pattern the deprecated _fire_cron_job_for_profile uses for its store scope), and pull the port with cfg_get. Test updated to stub load_config rather than write a raw config.yaml. * fix(gateway): only messaging platforms count for the scale-to-zero arm gate The stage2 hook now generates API_SERVER_KEY for every Docker container, and key presence force-enables the api_server platform. The scale-to-zero arm gate counted every enabled platform, so the loopback api_server listener made messaging_is_relay_only_or_absent False on every hosted instance — silently disarming the feature (the not-armed log would show enabled platforms=['relay','api_server']). The arm gate and the not-armed logger now share one helper that filters to enabled MESSAGING platforms, excluding LOCAL/API_SERVER/WEBHOOK — the same non-messaging exclusion set _connect_platforms already uses. A genuinely enabled direct-socket platform (Discord/Telegram) still disarms. Two of the three new tests fail without this fix.
…ify-style blocking bridge New desktop_ui tool: the agent proposes an MCP server (install/enable/ authorize + a one-line reason) and blocks on mcp.setup.request until the renderer's consent card answers mcp.setup.respond with the outcome (installed/enabled/authorized/declined/unanswered/error). Same lifecycle as clarify: 10-min timeout, allow_expired late answers, tool lifecycle events forced on so the card mounts even with tool progress off. Desktop prompt hint steers the model to the tool instead of hand-editing config; every other surface keeps the schema out and is pointed at hermes mcp install.
… transcript The card follows the approval bar's consent vocabulary (primary-tinted action + ghost decline, ⌘⏎/Esc with clarify's focus-stand-down rule) on clarify's widget shell. Install prefers the reviewed catalog entry (env prompts inline, background installs polled to completion) and falls back to the desktop suggestion directory via the validated add-server POST + OAuth; success reloads live MCP tools before unblocking the agent so it resumes with the tools it was just promised. Esc stays live mid-flight as cancel — the abandoned flow aborts at its next poll and a post-write cancel rolls the config entry back. Typing while the card is pending declines it and sends normally (skipClarifyRequest's pattern), and the request/tool.start rows merge on the server arg so reconnects can't double-render the card.
…ills A renderer-local directory of official hosted MCP remotes (URL-only, vendor-documented endpoints — deliberately not the reviewed install catalog) powers keyword and pasted-link suggestions: typing jira or pasting a *.atlassian.net URL floats an 'Add Atlassian' pill in the composer's micro-action strip. Matching is whole-word/phrase (unicode boundaries) plus strict host-suffix on links, host hits outrank keywords, capped at two, debounced 600ms, and excludes servers already in mcp_servers. Pills are session-scoped like the micro-action badges and self-limiting rather than dismissible — they exist only while a trigger is in the draft. A click drafts the setup request; the agent's setup_mcp card carries the consent. Brand glyphs extracted from the mcp-tab into lib/mcp-brands (shared, monochrome marks follow the theme so GitHub/Notion/Vercel survive dark mode).
The toolset inventory and the post-hook ownership contract both enumerate the GUI tools; the new tool joins both lists (and the emit-once parametrization actually exercises its executor path).
The clarify schema now tells the model to order choices best-first, and mark_recommended tags element 0 with "(Recommended)" at the tool layer -- the one platform-agnostic entry point -- so CLI, TUI, desktop, and every messaging adapter inherit the label without a copy each. Each surface already defaults its cursor to index 0, so the recommendation is the pre-highlighted row too. The label is presentation only: strip_recommended takes it back off user_response, and choices_offered reports the bare list, so the agent never reasons about (or echoes back) a string it did not write. Typed replies on messaging platforms match with or without the suffix.
The card reads the labelled choices off the gateway request rather than the raw tool args -- the backend applies the label there, and the card only mounts once the request exists, so the args are a hydration-race fallback. RECOMMENDED_LABEL and bareChoice live in the clarify store so the component and the choice-length guard share one definition; without the guard a long option could be dropped for length the label added.
…ovider bus The pill strip from the inline-MCP work is worth more than one source, so the MCP-specific store splits into two layers with the same UX contract (session-scoped, capped, self-limiting, one-click with narrated idle→working→done): - store/composer-suggestions.ts — the bus. Draft providers register into the existing debounced sampler; event providers push/withdraw directly. Offerings merge (event before draft), dedupe by provider-namespaced key, and keep reference identity on no-ops. - store/suggestion-providers/mcp.ts — the founding provider, behavior unchanged: directory keyword/host matching, configured-server exclusion, one-click connect with OAuth cancel + config rollback. - composer/suggestion-pills.tsx — the generic strip; phases and cancel live here, action/rollback/toasts stay with the provider's invoke. No new pills yet — this is the seam for them.
…n doors (NousResearch#85093) Desktop plugins reach the backend exclusively through the generic ws JSON-RPC door (host.request), but profile enumeration/creation only existed on the dashboard REST router, which plugins cannot reach — so anything 'one chat per agent profile'-shaped (bot rosters, profile pickers, team panes) was impossible to build as a plugin. - tui_gateway/methods_profiles.py: new @method handlers * profiles.list — profiles + optional last_session preview per profile (mirrors session.list's kanban/tool deny-list; best-effort per-profile state.db probe degrades to null instead of failing the call) * profiles.create — ws twin of POST /api/profiles (clone_from/clone_all/ no_skills/description), plus optional SOUL.md content and a best-effort model+provider pin; mirrors the CLI flow (seed skills, safe alias) Both run on the RPC pool, not the WS reader thread (list_profiles walks skill trees; create copies bundles). - SDK: host.openSession(id, { profile, intent }) — open a stored session the way core surfaces do, soft-swapping to the owning profile's backend first (ensureGatewayProfile), and host.newChat(profile) — fresh draft in a named profile (same door as the sidebar's per-profile '+'). - Docs: desktop-plugin-sdk.md gains both surfaces. First consumer: a Grok Bot-style 'Bots' roster plugin (one persistent chat per agent profile with a New Agent dialog) built on exactly these four doors.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
PrivilegedIntentsRequired is a Developer Portal config error; surface which intents Hermes requested as a non-retryable fatal and teach setup/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
The cherry-picked NousResearch#79448 predated NousResearch#85049's _classify_connect_exception, so it added a parallel PrivilegedIntentsRequired branch ahead of the classifier (plus its own _is_privileged_intents_required detector). Fold the tailored guidance into the classifier's existing intents arm instead: one classification path, one error code (discord_intents_required), and the message now names exactly the intents Hermes requested (Message Content always; Server Members only when username/role allowlists need it). Wizard callout, docs corrections, and tests from NousResearch#79448 kept as-is.
The bus now keeps a session-scoped declined ledger: a pill the user watched appear and let die three times stops re-offering for the rest of the session. Acting on a pill clears its count, so a suggestion that was taken can come back for the next trigger. In-memory on purpose — a fresh session is a fresh chance.
Two new sources on the suggestion bus, one per provider shape: - skill (draft): the draft names an enabled skill (whole-word, 4+ chars), so offer to lead the message with its /command. Invoke prefixes the draft via the new 'prefix' insert mode and stands down once the draft starts with a slash; skill_manage invalidates the cached index alongside the slash-completion cache. - repair (event): an mcp__ tool call failing with auth/connection- shaped output offers a one-click reconnect for that server, fed from the gateway tool.complete handler. Reconnect runs the shared OAuth flow with server-side cancel and reloads live tools before claiming success; a later successful call to the same server withdraws the offer on its own.
Third draft provider: recurring phrasing in the draft ("every morning",
"daily", "each week") offers a Schedule-this pill. Click prefixes the
draft with an explicit scheduling instruction and the agent creates the
job via its cronjob tool on send — the pill never schedules anything
itself. Proper-noun guard keeps titles like "the Daily Prophet" quiet;
hyphen-as-word-char keeps "weekly-report.pdf" quiet.
… recents The card prop was gated off whenever Project grouping was active, so the Inbox style toggle silently did nothing there. It is a render variant, not a grouping: project lanes and overview previews now render the same card the flat list does. Also mirrors the section's real virtualization inputs (projectOverview / entered-project content, not the persistent agentProjectTree cache) when deciding the wrapper's scroll classes, and stops gating SCROLL_Y on that parallel guess — the section is the single authority on which scroller lives, so the recents pane can no longer end up with no scroller at all (the "no sessions under Updated grouping after toggling settings" blank).
scrollbar-overlay opts out of the themed thin scrollbar; on Windows there are no native overlay scrollbars, so Chromium painted the classic always-visible gutter instead — a permanent scrollbar next to the recents list. The themed fade bar reserves its 4px on every platform but stays invisible until hover, and the wrapper no longer stacks a second scroller, which is what the overlay class was originally working around.
The Project-grouping flag was one global bool while the grouping beneath it was already stored per scope (workspace vs all-profiles). Picking Project inside a workspace therefore dragged the all-profiles view into the project tree and vice versa — "I have to re-set grouping every time I switch." The flag now lives per scope like its sibling grouping atoms (the flat key keeps its historical name so existing choices survive), setSidebarGrouping writes to the scope it just switched INTO when Profile flips the view, and reset clears both scopes.
…un (NousResearch#85111) A profile created through the headless ws door (profiles.create, NousResearch#85093) was born with no inference provider: create_profile() seeds a comment-only .env, never copies auth.json, and a fresh profile has no config.yaml. Its first message failed with 'No inference provider configured' and the flow has no interactive setup step to recover with. New mirror_credentials param (default true): copy the launch profile's .env (only over the seeded stub — never clobber cloned secrets) and auth.json (only when absent), both chmod 600, and inherit model.provider/model.default when the caller gave no explicit pin and no config was cloned. mirror_credentials:false preserves the old isolated behavior byte-for-byte. Result gains a mirrored:{env,auth,model_inherited} receipt. CLI and REST create paths untouched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated fail-closed upstream sync. Locally verified head: 8f86ba2