Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6e1e766
fix(kanban): honor explicit platform tool opt-ins across configuratio…
Xipong Sep 10, 2026
5e3615a
fix(kanban): drop the TUI empty-selection change and refit tests to t…
teknium1 Sep 12, 2026
24a6272
fix(redact): AgentMail prefix rule matches any opaque key body, not o…
teknium1 Sep 12, 2026
80100dc
fix(mcp): same-named MCP servers with different credentials connect p…
teknium1 Sep 11, 2026
1373f67
test(kanban): align worker toolset expectation with explicit opt-in
mrkillbob Sep 13, 2026
4bccc48
docs(kanban): per-platform opt-in is the documented path (hermes tool…
teknium1 Sep 12, 2026
c03c768
fix(mcp): preserve scoped reload names and stdio identities
mrkillbob Sep 13, 2026
9fbbaa3
fix(profiles): --clone leaves messaging channels behind; --clone-chan…
teknium1 Sep 13, 2026
5fc2d73
fix(gateway): register gateway.multiplex_profiles; explicit migrate -…
teknium1 Sep 13, 2026
691d2c6
fix(mcp): scope provenance and parallel policy
mrkillbob Sep 13, 2026
25b3ac0
fix(gateway): preserve upstream multiplex migration dependencies
mrkillbob Sep 13, 2026
8fd6e64
merge: include PR88 before final PR91 sync
mrkillbob Sep 13, 2026
1d4e8ca
feat(gateway): multiplexer hot-serves profiles created while it runs,…
teknium1 Sep 12, 2026
77dd9ff
fix(gateway): hot-serve reaches pooled Desktop backends; deleted prof…
teknium1 Sep 12, 2026
6705334
fix(gateway): a secondary API_SERVER_KEY no longer skips the profile;…
teknium1 Sep 11, 2026
e341c09
fix(gateway): complete profile multiplex migration review fixes
mrkillbob Sep 13, 2026
e4f4911
fix(mcp): preserve scoped connection provenance across reloads
mrkillbob Sep 13, 2026
33b7e31
fix(desktop): keep hot-served messaging updates type-safe
mrkillbob Sep 13, 2026
e5dca6b
fix(gateway): restore multiplex CI compatibility seams
mrkillbob Sep 13, 2026
447bc88
fix(gateway): address remaining multiplex review feedback
mrkillbob Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@
r"pypi-[A-Za-z0-9_-]{10,}", # PyPI API token
r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT
r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth
r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key
# AgentMail API key: ``am_`` / ``am_org_`` + an opaque alphanumeric body. The body has no ``_``/``-``,
# which is what separates it from ``am_example_identifier_123`` (#10983); public docs pin only the
# prefix, so the charset stays broad and the length floor does the discriminating.
r"am_(?:org_)?[A-Za-z0-9]{20,}",
r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash)
r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key
r"exa_[A-Za-z0-9]{10,}", # Exa search API key
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/api/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ export function getMessagingPlatforms(profile?: null | string): Promise<Messagin
})
}

/** `hot_served`: a live multiplexer serving this named profile rebuilt its adapters from the new
* credentials right away — no gateway restart is needed for the change to take effect. */
export interface MessagingPlatformUpdateResponse {
hot_served?: boolean
ok: boolean
platform: string
}

export function updateMessagingPlatform(
platformId: string,
body: MessagingPlatformUpdate,
profile?: null | string
): Promise<{ ok: boolean; platform: string }> {
return hermesApi<{ ok: boolean; platform: string }>({
): Promise<MessagingPlatformUpdateResponse> {
return hermesApi<MessagingPlatformUpdateResponse>({
...profileScoped(profile),
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`,
method: 'PUT',
Expand Down
29 changes: 22 additions & 7 deletions apps/desktop/src/app/messaging/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,18 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
[m, scopeProfile]
)

// A multiplexed named profile is re-served from its new config at once (`hot_served`): no restart
// banner; re-read status once the adapter had a moment to connect. Anything else keeps the restart
// action on the success toast.
const settleAfterUpdate = useCallback(
(hotServed: boolean | undefined) => {
if (hotServed) {
window.setTimeout(() => void refreshPlatforms(true), 4000)
}
},
[refreshPlatforms]
)

// Pairing has its own signal. platforms.changed tracks connect/disconnect
// health via gateway_state.json, which a new pairing request never moves —
// riding it would leave a pending row invisible until something unrelated
Expand Down Expand Up @@ -297,7 +309,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setSaving(`enabled:${platform.id}`)

try {
await updateMessagingPlatform(platform.id, { enabled }, scopeProfile)
const result = await updateMessagingPlatform(platform.id, { enabled }, scopeProfile)
setPlatforms(
current =>
current?.map(row =>
Expand All @@ -310,11 +322,12 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
: row
) ?? current
)
settleAfterUpdate(result.hot_served)
notify({
kind: 'success',
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply,
action: restartGatewayAction
message: result.hot_served ? m.appliedLive : m.restartToApply,
action: result.hot_served ? undefined : restartGatewayAction
})
} catch (err) {
notifyError(err, m.failedUpdate(platform.name))
Expand All @@ -333,14 +346,15 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setSaving(`env:${platform.id}`)

try {
await updateMessagingPlatform(platform.id, { env }, scopeProfile)
const result = await updateMessagingPlatform(platform.id, { env }, scopeProfile)
setEdits(current => ({ ...current, [platform.id]: {} }))
await refreshPlatforms()
settleAfterUpdate(result.hot_served)
notify({
kind: 'success',
title: m.setupSaved(platform.name),
message: m.restartToReconnect,
action: restartGatewayAction
message: result.hot_served ? m.connectingLive : m.restartToReconnect,
action: result.hot_served ? undefined : restartGatewayAction
})
} catch (err) {
notifyError(err, m.failedSave(platform.name))
Expand All @@ -353,7 +367,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setSaving(`clear:${key}`)

try {
await updateMessagingPlatform(platform.id, { clear_env: [key] }, scopeProfile)
const result = await updateMessagingPlatform(platform.id, { clear_env: [key] }, scopeProfile)
setEdits(current => ({
...current,
[platform.id]: {
Expand All @@ -362,6 +376,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
await refreshPlatforms()
settleAfterUpdate(result.hot_served)
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
} catch (err) {
notifyError(err, m.failedClear(key))
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,8 @@ export const ar = defineLocale({
restartToApply: 'أعد التشغيل لتطبيق التغييرات.',
setupSaved: name => `تم حفظ إعداد ${name}`,
restartToReconnect: 'أعد التشغيل لإعادة الاتصال.',
appliedLive: 'تم التطبيق على البوابة قيد التشغيل.',
connectingLive: 'البوابة قيد التشغيل تتصل باستخدام بيانات الاعتماد الجديدة.',
keyCleared: key => `تم مسح ${key}`,
setupUpdated: name => `تم تحديث إعداد ${name}`,
failedUpdate: name => `فشل تحديث ${name}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1962,6 +1962,8 @@ export const en: Translations = {
restartToApply: 'This change takes effect after a gateway restart.',
setupSaved: name => `${name} setup saved`,
restartToReconnect: 'New credentials take effect after a gateway restart.',
appliedLive: 'Applied to the running gateway.',
connectingLive: 'The running gateway is connecting with the new credentials.',
keyCleared: key => `${key} cleared`,
setupUpdated: name => `${name} setup was updated.`,
failedUpdate: name => `Failed to update ${name}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,8 @@ export const ja = defineLocale({
restartToApply: 'この変更はゲートウェイの再起動後に有効になります。',
setupSaved: name => `${name} の設定を保存しました`,
restartToReconnect: '新しい認証情報はゲートウェイの再起動後に有効になります。',
appliedLive: '実行中のゲートウェイに適用されました。',
connectingLive: '実行中のゲートウェイが新しい認証情報で接続しています。',
keyCleared: key => `${key} をクリアしました`,
setupUpdated: name => `${name} の設定が更新されました。`,
failedUpdate: name => `${name} の更新に失敗しました`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,8 @@ export const ru = defineLocale({
restartToApply: 'Это изменение вступит в силу после перезапуска шлюза.',
setupSaved: name => `Настройка ${name} сохранена`,
restartToReconnect: 'Новые учётные данные вступят в силу после перезапуска шлюза.',
appliedLive: 'Применено к работающему шлюзу.',
connectingLive: 'Работающий шлюз подключается с новыми учётными данными.',
keyCleared: key => `${key} очищено`,
setupUpdated: name => `Настройка ${name} обновлена.`,
failedUpdate: name => `Не удалось обновить ${name}`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1743,6 +1743,8 @@ export interface Translations {
restartToApply: string
setupSaved: (name: string) => string
restartToReconnect: string
appliedLive: string
connectingLive: string
keyCleared: (key: string) => string
setupUpdated: (name: string) => string
failedUpdate: (name: string) => string
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1665,6 +1665,8 @@ export const zhHant = defineLocale({
restartToApply: '此變更將在閘道重新啟動後生效。',
setupSaved: name => `${name} 設定已儲存`,
restartToReconnect: '新憑證將在閘道重新啟動後生效。',
appliedLive: '已套用到執行中的閘道。',
connectingLive: '執行中的閘道正在使用新憑證連線。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 設定已更新。`,
failedUpdate: name => `更新 ${name} 失敗`,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,8 @@ export const zh: Translations = {
restartToApply: '此更改将在网关重启后生效。',
setupSaved: name => `${name} 设置已保存`,
restartToReconnect: '新凭据将在网关重启后生效。',
appliedLive: '已应用到正在运行的网关。',
connectingLive: '正在运行的网关正在使用新凭据连接。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 设置已更新。`,
failedUpdate: name => `更新 ${name} 失败`,
Expand Down
7 changes: 5 additions & 2 deletions cron/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ zero outside a kanban task (footprint ladder rung 3).
notify-*, dispatch, daemon, gc`. Argparse alias dispatch must accept both `list` and `ls` (root).
- **Toolset:** `tools/kanban_tools.py` — `kanban_show, kanban_complete, kanban_request_review,
kanban_request_changes, kanban_block, kanban_heartbeat, kanban_comment, kanban_create, kanban_link,
kanban_attach, kanban_attach_url, kanban_attachments`; profiles enabling `kanban` outside a
dispatched task also get `kanban_list` and `kanban_unblock` for board routing.
kanban_attach, kanban_attach_url, kanban_attachments`; platforms whose saved selection enables
`kanban` (`hermes tools enable kanban --platform <p>`; default-off, in `CONFIGURABLE_TOOLSETS`) get
the full set plus `kanban_list`/`kanban_unblock` for board routing. The check_fn reads the schema
build's own selection (`tools/kanban_toolset_context.py`), never the legacy top-level `toolsets`
key alone.
- **Dispatcher:** long-lived loop (default 60s) that reclaims stale claims, promotes ready tasks,
atomically claims, and spawns assigned profiles. Runs **inside the gateway** by default
(`kanban.dispatch_in_gateway: true`). Standalone: `plugins/kanban/systemd/hermes-kanban-dispatcher.service`.
Expand Down
20 changes: 15 additions & 5 deletions cron/scheduler_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ def _existing_profile_homes(profile_homes: list) -> list:
profile's home untouched, which is the correct invariant: a home that does not exist cannot hold jobs to
fire.
"""
if callable(profile_homes):
# Live enumerator (multiplex gateway): a profile created after startup is ticked without a
# restart; a raising enumerator keeps this cycle at zero homes rather than killing the ticker.
try:
profile_homes = list(profile_homes())
except Exception:
logger.warning("cron profile enumeration failed; skipping this cycle", exc_info=True)
return []
return [entry for entry in profile_homes if Path(_profile_entry(entry)[1]).is_dir()]


Expand Down Expand Up @@ -393,7 +401,7 @@ def start(
# jobs actually fire instead of languishing in a store no ticker owns (#69377). Without this, only
# the process-global HERMES_HOME (the default profile) is ticked. Heartbeats and recovery are also
# scoped per profile so `hermes cron status` reflects liveness for every profile independently.
if profile_homes:
if profile_homes is not None and (callable(profile_homes) or profile_homes):
self._start_multiplex(
stop_event, profile_homes=profile_homes, adapters=adapters, loop=loop,
interval=interval, can_dispatch=can_dispatch, profile_adapters=profile_adapters,
Expand Down Expand Up @@ -462,10 +470,12 @@ def _start_multiplex(
)
from cron.jobs import clear_ticker_error, record_ticker_error, record_ticker_heartbeat

initial_homes = _existing_profile_homes(profile_homes)
logger.info(
"Multiplex cron scheduler started for %d profile(s): %s",
len(profile_homes),
[p[0] if isinstance(p, tuple) else p for p in profile_homes],
"Multiplex cron scheduler started for %d profile(s): %s%s",
len(initial_homes),
[p[0] if isinstance(p, tuple) else p for p in initial_homes],
" (re-enumerated every cycle)" if callable(profile_homes) else "",
)

def tick_adapters_for(profile_name):
Expand All @@ -482,7 +492,7 @@ def tick_adapters_for(profile_name):
# Recovery + heartbeat per profile; one broken store must not abort startup for the others.
# A profile may have been deleted since this snapshot was taken; never recreate a deleted home's
# cron workspace via the heartbeat below (#47368).
for entry in _existing_profile_homes(profile_homes):
for entry in initial_homes:
_, home = _profile_entry(entry)
try:
with _profile_cron_scope(home):
Expand Down
6 changes: 3 additions & 3 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import os
from pathlib import Path
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from typing import Dict, List, Optional, Any, Callable
from typing import Dict, List, Optional, Any, Callable, Mapping
from enum import Enum

from hermes_cli.config import get_hermes_home
Expand Down Expand Up @@ -73,15 +73,15 @@ def _normalize_multiplex_profile_allowlist(value: Any) -> Optional[List[str]]:
return normalized


def _env_multiplex_profiles_override() -> "bool | None":
def _env_multiplex_profiles_override(environ: Optional[Mapping[str, str]] = None) -> "bool | None":
"""GATEWAY_MULTIPLEX_PROFILES operator override: True/False for a recognized token.

``None`` when unset, blank, or unrecognized so the caller keeps the config.yaml
value (env > config > default). Blank is deliberately ``None``, not ``False``:
a provisioned-but-unpopulated Fly secret arrives as ``""`` and must NOT shadow
a config.yaml opt-in.
"""
raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES")
raw = (environ or os.environ).get("GATEWAY_MULTIPLEX_PROFILES")
if not (raw or "").strip():
return None
parsed = _bool_token(raw)
Expand Down
18 changes: 17 additions & 1 deletion gateway/config_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
PlatformConfig,
_getenv_str,
_has_usable_api_server_key,
platform_binds_port,
)
from utils import is_truthy_value

Expand Down Expand Up @@ -168,6 +169,15 @@ def _env_reply_mode(config: GatewayConfig, platform: Platform, env: str) -> None
config.platforms.setdefault(platform, PlatformConfig()).reply_to_mode = mode


def _loading_secondary_under_multiplexer() -> bool:
"""True while a multiplexer loads a NON-default profile's config (``_profile_runtime_scope`` sets the
home override; the runner sets the multiplex flag). Same signal ``gateway.config`` uses for scoped reads."""
from agent.secret_scope import is_multiplex_active
from hermes_constants import get_hermes_home_override, profile_name_for_home
override = get_hermes_home_override()
return bool(override) and is_multiplex_active() and profile_name_for_home(override) != "default"


def _enable_from_env(
config: GatewayConfig, platform: Platform, *, pop_marker: bool = False, warn: bool = True
) -> PlatformConfig:
Expand All @@ -184,7 +194,13 @@ def _enable_from_env(
explicit = extra.pop("_enabled_explicit", False) if pop_marker else extra.get("_enabled_explicit", False)
if platform_config.enabled:
return platform_config
if not explicit:
if not explicit and not (
platform_binds_port(platform.value, extra) and _loading_secondary_under_multiplexer()
):
# A secondary's port-binding credential (the docs require API_SERVER_KEY in its .env for
# /p/<profile>/ auth) must not turn into listener intent: the default profile owns the one
# shared listener and ``_load_secondary_profile_config`` skips the WHOLE profile for it (#100397).
# The credential itself still lands in ``extra`` for the shared adapter to authenticate with.
platform_config.enabled = True
elif warn:
_warn_explicit_disable_beats_env(platform)
Expand Down
8 changes: 8 additions & 0 deletions gateway/control_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,11 @@ def pause_gateway_for_update(home: Path, *, timeout: float = _DEFAULT_CLIENT_TIM
Step 2 of the socket migration (#92091).
"""
return query_gateway_control(home, "pause-for-update", timeout=timeout)


def rescan_gateway_profiles(home: Path, *, timeout: float = 8.0) -> Optional[dict[str, Any]]:
"""Ask the multiplexer serving ``home`` to reconcile ``profiles/`` now (hot-serve a created profile,
unroute a deleted one). Returns its ``{"served_profiles", "added", "removed", ...}`` answer, or None
when no gateway answers / the gateway predates the verb — callers then rely on the periodic rescan
(or the restart reminder)."""
return query_gateway_control(home, "rescan-profiles", timeout=timeout)
2 changes: 2 additions & 0 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,8 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
# Stateless request/response (``send()`` is a stub): async-delivery tools must not promise
# delivery here, and a resumed turn completes the work rather than asking.
supports_async_delivery: bool = False
# ``/p/<profile>/v1/...`` on the shared listener (``_make_profile_prefix_middleware``).
serves_profile_prefix: bool = True
# Same statelessness applies to the startup auto-resume prompt: no client is waiting to answer "session
# restored — what next?", so a resumed turn should complete the interrupted work rather than acknowledge
# (#57056).
Expand Down
5 changes: 5 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,11 @@ def set_status_text(self, chat_id: str, text: Optional[str]) -> None:
# answer, and an acknowledgement would silently abandon the task (#57056). Read generically via
# ``getattr(adapter, "interactive_resume", True)`` — no per-platform branching at the call site.
interactive_resume: bool = True
# Port-binding adapter that answers ``/p/<profile>/...`` for every served profile on the default
# listener under ``gateway.multiplex_profiles``. Declared per adapter (not in a central list) so
# ``hermes gateway migrate`` can tell "URL changes" from "this profile would be skipped" as new
# HTTP-inbound adapters gain the prefix.
serves_profile_prefix: bool = False
# Back-reference to the running ``GatewayRunner`` (set by gateway/run.py); ``build_source``
# resolves the inbound profile via ``runner._profile_name_for_source``.
gateway_runner = None # type: ignore[assignment]
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ def _ok():


class BlueBubblesAdapter(BasePlatformAdapter):
# Answers /p/<profile>/... on the default listener for a served secondary (shared_ingress).
serves_profile_prefix: bool = True
platform = Platform.BLUEBUBBLES
SUPPORTS_MESSAGE_EDITING = False
MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/msgraph_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ def _resolve(match: re.Match[str]) -> str:

class MSGraphWebhookAdapter(BasePlatformAdapter):
"""Receive Microsoft Graph change notifications and surface them internally."""
# Answers /p/<profile>/... on the default listener for a served secondary (shared_ingress).
serves_profile_prefix: bool = True

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MSGRAPH_WEBHOOK)
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ class WebhookAdapter(BasePlatformAdapter):
# The startup auto-resume turn must instruct the model to FINISH the interrupted work instead of
# emitting an interactive acknowledgement that abandons the task (#57056).
interactive_resume: bool = False
# ``/p/<profile>/webhooks/<route>`` on the shared listener (``_resolve_request_profile``).
serves_profile_prefix: bool = True

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WEBHOOK)
Expand Down
Loading
Loading