Commit graph

130 commits

Author SHA1 Message Date
Teknium
5600105478 refactor(gateway): migrate slack/dingtalk/whatsapp/matrix/feishu/telegram/wecom/email/sms adapters to bundled plugins
Salvage of PR #41284 onto current main. Relocates the last 9 inline messaging
adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite,
wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained
bundled plugins under plugins/platforms/<x>/, discovered via the platform
registry. Strips the per-platform core touchpoints from gateway/run.py,
gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and
tools/send_message_tool.py.

Carries forward the migration fixes (explicit enabled:false honored,
get_connected_platforms forces discovery, plugin is_connected via
gateway.get_env_value, logs --component gateway matches plugins.platforms.*,
matrix hidden on Windows).

Additionally ports config keys main added since the PR base: the matrix
plugin's _apply_yaml_config now also covers allowed_users,
ignore_user_patterns, process_notices, and session_scope (the inline
gateway/config.py matrix block gained these in the 1340 commits the PR sat
open; they would otherwise have been silently dropped on deletion).
2026-06-20 10:26:45 -07:00
kshitijk4poor
a7dd98c860 fix(env): guard remaining malformed int/float env var casts with utils helpers
Widen the env_float() guard from #48735 across the whole bug class: a
non-numeric value (e.g. a stale .env "HERMES_API_TIMEOUT=abc" or a typo'd
port) raised an unhandled ValueError and crashed adapter/agent init.

Converts 22 genuinely-unguarded first-party int/float(os.getenv()) sites to
the canonical utils.env_int / utils.env_float helpers (the established house
pattern), instead of duplicating per-module helpers or inline try/except:

- gateway/config.py: WECOM_CALLBACK_PORT, BLUEBUBBLES_WEBHOOK_PORT
- gateway/platforms/email.py: EMAIL_IMAP/SMTP_PORT, EMAIL_POLL_INTERVAL
- gateway/platforms/feishu.py: dedup cache + text/media batch settings
- gateway/platforms/wecom.py, discord/adapter.py: text batch delays
- gateway/platforms/telegram.py: media batch delay, TELEGRAM_WEBHOOK_PORT
- gateway/platforms/whatsapp.py: WHATSAPP_NPM_INSTALL_TIMEOUT
- hermes_cli/auth.py: CODEX/XAI refresh timeouts
- agent/chat_completion_helpers.py: API/stream read/stale timeouts
- run_agent.py, agent/auxiliary_client.py: API + nous timeouts

Sites already guarded by try/except or local helpers are left untouched.
The HERMES_MAX_ITERATIONS sites are already guarded on main via
_current_max_iterations(), so they are not included.
2026-06-20 14:54:36 +05:30
ruangraung
8cf7df867e fix(plugins): silence raft check_fn log spam for users without raft CLI
The raft platform plugin's check_raft_requirements() logged a WARNING every
time it returned False. Since check_fn is called on every load_gateway_config()
(~every 10s during normal gateway operation), users who don't have the raft
CLI installed get their logs flooded with no way to suppress it — hermes plugins
disable doesn't work for bundled platform plugins, and platforms.raft.enabled:
false doesn't gate the check_fn call.

Fix: make check_raft_requirements() a silent predicate (return True/False
only, no logging), matching the convention documented and used by other
platform adapters (e.g. teams/adapter.py). The caller in
gateway/platform_registry.py create_adapter() already emits its own warning
when requirements aren't met and an adapter is actually requested — that's the
correct place for a user-facing warning (fires once per connect attempt, not
once per config load).

Fixes #49234
2026-06-19 17:12:58 -07:00
Teknium
5f55f0ff85
feat(teams): native send_video/send_voice/send_document attachments (#49308)
Teams overrode send_image/send_image_file but not send_video, send_voice,
or send_document — so when the gateway dispatched a video/voice/document
reply to a Teams chat it fell through to the base-class text fallback and
sent the local file path as plain text (same broken-UX class as the LINE
URL-image gap in #49298).

Extract the existing send_image attachment logic into a shared
_send_media_attachment helper (remote URL by reference, local file as a
base64 data URI, MIME guessed from the path) and route all four media
kinds through it. 5 new tests cover remote-URL, local-file base64,
no-app, and missing-file paths.
2026-06-19 16:20:59 -07:00
Teknium
ba49fb51a5
fix(discord): hydrate channel context when replying to a message (#49212)
* fix(discord): hydrate channel context when replying to a message

Replying to a message in a free-response (non-mention, threads-off)
channel previously received only the 500-char "[Replying to: ...]"
snippet — the history-backfill gate fired only for mention-gated
channels and threads, so a reply got no surrounding channel context.

Replies now route through the same _fetch_channel_context hydration
that threads use. When the user replied to a specific (often older)
message, a reply-anchored window is scanned ending at that message so
the agent sees the exchange around what was pointed at, even when the
target sits before the self-message partition. The two windows are
merged chronologically and de-duplicated by message id.

Also hardens the recent-window scan to skip non-conversational status
bumps before the self-message partition check, and makes author-name
resolution defensive against partial/deleted authors.

* fix(discord): duck-type reply-target resolution instead of isinstance(discord.Message)

The e2e suite stubs the discord module, so discord.Message is a MagicMock
and isinstance(_resolved, discord.Message) raises 'isinstance() arg 2 must
be a type'. Any object with an int .id works as a scan anchor, so resolve
the reply target by duck-typing on .id and fall back to a _Snowflake from
the reference message_id.
2026-06-19 12:03:08 -07:00
teknium1
7d86178cf5 fix(raft): set stdin=DEVNULL on bridge subprocess
Satisfies the repo-wide subprocess-stdin guard
(tests/tools/test_subprocess_stdin_guard.py); the long-lived bridge
child should not inherit the gateway's stdin.
2026-06-19 07:52:37 -07:00
skyzh
9026a8c789 feat(gateway): add Raft bundled platform plugin with activity hooks
Adds a Raft platform adapter as a bundled plugin (plugins/platforms/raft/)
connecting Hermes to Raft as an external agent via a wake-channel bridge.
The adapter starts a loopback HTTP endpoint, spawns 'raft agent bridge' as a
child process, and injects content-free wake hints into the gateway session
pipeline. The agent reads/sends messages through the Raft CLI; the adapter
never touches message bodies or delivery cursors. Activity observer hooks
report tool/LLM/session lifecycle events via a bounded at-most-once queue.
Auto-enables when RAFT_PROFILE is set.

Cherry-picked from PR #47629. Authored by skyzh (@xxchan).
2026-06-19 07:52:37 -07:00
snav
caaa916289 fix(gateway): don't let delayed Discord status messages partition history backfill
Discord channel-history backfill partitions on Hermes' last self-authored
message. Asynchronous, non-conversational status sends (self-improvement
review bubbles, heartbeats, background-process notifications, update status,
gateway restart/online notices) land as ordinary bot messages, so a delayed
status bump becomes the history boundary and swallows real messages that
arrived after Hermes' actual reply.

Mark these sends at the source via metadata["non_conversational"] (Discord
only; other platforms' metadata is unchanged). The adapter no longer advances
the history-boundary cache for marked sends and persists their IDs to a
sidecar JSON so the cold-start scan can skip them by ID after a restart. A
narrow regex recognizer remains only as an upgrade bridge for status bumps
emitted by an older gateway that pre-dates the marking.
2026-06-19 07:29:27 -07:00
Kenny John Jacob
bce1e36b57 fix(discord): unwrap dict choices + soft-boundary truncate clarify buttons
Two bugs surfaced from production usage in #37134:

1. Dict choices rendered as Python repr. LLMs sometimes emit
   [{"description": "..."}] instead of bare strings; the old
   str(c).strip() coercion turned the whole dict into
   "{'description': '...'}" on the button label.

   Fix: add a _flatten_choice helper that unwraps dicts against
   the canonical LLM tool-call user-facing keys (label, description,
   text, title) in that order. Dicts with none of those keys are
   dropped. The "name" and "value" keys are deliberately NOT in the
   priority list — they're Discord-component-shaped fields that
   could appear in dicts that aren't meant to be choices (a
   developer-error wiring that passes a Button-shaped object);
   picking them would leak raw enum values or 4-char model
   identifiers onto user-facing buttons.

2. Mid-word truncation on long button labels. The old
   choice[:72] + "..." cut at position 72, mid-word. Worse, the
   three-char ellipsis ate into the 80-char Discord label cap,
   leaving only 75 chars of body.

   Fix: budget-aware cut strategy with three tiers:
     a. Last space in the trailing half of the budget (word boundary).
     b. Last soft boundary (- , . )) in the trailing half — used
        only when no word boundary exists.
     c. Hard cut at the budget limit (last resort).
   Use single U+2026 (…) to fit the cap. Cut AT soft boundaries
   (inclusive) so the label ends on the boundary char rather than
   on the alpha char that followed it.

Tests:
- test_unwraps_dict_choices_to_description: reproduces the
  screenshot in #37134, asserts the Python repr is gone.
- test_unwrap_prefers_description_over_name_in_multi_key_dict:
  regression guard for the name-key order in the unwrap list.
- test_unwrap_prefers_label_over_description: regression guard
  for label winning over description.
- test_unwrap_does_not_pick_value_or_name_alone: regression
  guard for the "name"/"value" fields being absent.
- test_truncates_long_choice_label: 200-char input, asserts
  total <= 80 and U+2026.
- test_truncates_long_choice_label_breaks_on_word_boundary:
  asserts the cut is on a space, not mid-word.
- test_truncates_long_no_space_choice_on_soft_boundary:
  adversarial input where position 76 is mid-word alpha, asserts
  the renderer falls back to a soft boundary.

Parity: telegram clarify suite (12 tests) still passes; the
helper is a Discord adapter local, not shared with the gateway.

Follow-up: gateway/platforms/telegram.py has the same str(c).strip()
pattern in its own send_clarify and will need a similar fix
(separate PR to keep this diff reviewable).

Fixes #37134
2026-06-19 06:31:08 -07:00
Austin Pickett
fd674af47f
fix(photon): preserve text in mixed iMessage attachments (salvage #46513) (#46818)
* fix(photon): preserve text in mixed iMessage attachments

When an iMessage bubble carried both text and an attachment, spectrum-ts'
inbound mapper returned only buildAttachmentMessage(...), dropping the user's
typed text before Hermes could see it. The Photon adapter then had no 'group'
content path, so the text was lost entirely.

- adapter.py: handle a new 'group' content type that flattens text + attachment
  items, preserving the typed text alongside cached media (extracted shared
  _normalize_binary_payload helper).
- sidecar: emit 'group' content in normalizeContent, and ship
  patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper
  (at npm postinstall AND at sidecar startup, so existing installs self-heal).

Windows robustness fixes on top of the original PR:
- The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which
  never matches on Windows (file:/// + drive letter) — it silently no-opped.
  Switched to pathToFileURL(argv[1]).href.
- The patcher matched \n-joined strings, so a CRLF checkout (Windows git
  autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching
  and restores the original EOL style on write.

Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>

* chore: map YuhangLin contributor email for attribution (#46513)

---------

Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-17 16:14:24 -05:00
underthestars-zhy
5b3fa26366 fix(photon): unify project identifiers and update documentation for Spectrum provisioning
Co-Authored-By: Marvin <marvin@photon.codes>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:25:56 -07:00
Teknium
5a0e0d35b9 fix(mattermost): preserve thread-local delivery hygiene
Salvage the valid thread-routing pieces from #41640:
- route Mattermost progress/status sends through metadata thread IDs
- treat top-level Mattermost channel posts as thread roots for progress
- preserve thread metadata through media/file sends
- allow flat fallback only for final notify-worthy replies on confirmed broken roots

Co-authored-by: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>
2026-06-15 15:06:23 -07:00
kshitij
cffd6e3c8d
Merge pull request #46078 from xxxigm/fix/discord-slash-command-100-cap
fix(discord): cap slash commands at Discord's 100-command limit
2026-06-16 02:05:31 +05:30
Austin Pickett
5f6be7f31b
fix(teams): package Microsoft Teams SDK as an installable extra (salvage #43945) (#46764)
* fix(teams): package Microsoft Teams SDK as an installable extra

The Teams adapter imports the microsoft-teams-apps SDK, but it was never
declared as a dependency, so source/local installs hit ImportError and the
adapter silently reported the SDK as unavailable. Add a 'teams' extra
(microsoft-teams-apps==2.0.13.4 + aiohttp) and document 'uv sync --extra teams'.

Per the 2026-05-12 [all] policy, opt-in messaging-platform SDKs are NOT added
to [all] (they would break every fresh install on a quarantined release); the
teams extra is installed on demand like the other platform backends.

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>

* chore: map rio-jeong contributor email for attribution (#43945)

* feat(teams): lazy-install the Teams SDK on demand (parity with other channels)

The teams extra alone left Teams as the only messaging platform that wouldn't
auto-install its SDK — every other channel (telegram, discord, slack, matrix,
dingtalk, feishu) lazy-installs via tools.lazy_deps on first connect. Bring
Teams to parity:

- Add 'platform.teams' to LAZY_DEPS (microsoft-teams-apps + aiohttp).
- Replace the passive 'check_teams_requirements = check_requirements' alias with
  a real lazy-installer that calls ensure_and_bind('platform.teams', ...),
  rebinding all Teams SDK globals on success (mirrors check_slack_requirements).
- Call check_teams_requirements() at the top of TeamsAdapter.connect() so
  enabling Teams installs the SDK on demand.
- Keep the passive check_requirements() as the registry check_fn so 'gateway
  status' probes never trigger a pip install.

The 'teams' extra remains for packagers / explicit 'uv sync --extra teams'.

Tests: rework the alias test into shortcircuit + lazy-install assertions, and
update test_connect_fails_without_sdk to simulate an uninstallable SDK.

---------

Co-authored-by: rio-jeong <rio.jeong@thebytesize.ai>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-15 14:35:15 -04:00
Teknium
efbe1635dd
fix(gateway): include replied-to media attachments (#46107) 2026-06-14 04:51:50 -07:00
xxxigm
5e851bc6bc fix(discord): cap slash commands at Discord's 100-command limit
Discord enforces a hard cap of 100 global application commands per app.
The adapter registers ~27 native commands plus every gateway-available
entry in COMMAND_REGISTRY plus all plugin commands plus the consolidated
/skill group. On a loaded install (many plugins/quick commands) the
desired set exceeds 100, so tree.sync() / _safe_sync_slash_commands()
hits error 30032 ("Maximum number of application commands reached") and
Discord rejects the ENTIRE batch — silently breaking every slash command,
not just the overflow.

Cap registration at the 100-command limit: native commands (registered
first, highest priority) and the /skill group are always kept; lower-
priority auto-registered COMMAND_REGISTRY and plugin commands are added
only until the cap is reached, with a single concise warning telling the
user how to surface the rest. Since both sync paths read from
tree.get_commands(), bounding the tree fixes the root cause for both.
2026-06-14 17:01:28 +07:00
Teknium
2681c5a12d
fix(photon): correct gateway start command (#45566) 2026-06-13 05:14:59 -07:00
Teknium
0fd34e8c5a
fix(teams): cache document/video/audio attachments and classify as DOCUMENT (#44778)
The Teams adapter only handled image/* attachments — documents (the
application/vnd.microsoft.teams.file.download.info consent-free download
payload and any direct-URL non-image attachment) never reached media_urls
at all, so run.py's document-context injection had nothing to surface.
Completes the class-wide sweep from PR #44695 (Signal/Email/SimpleX).

- download.info attachments: fetch the pre-authed SharePoint downloadUrl
  (SSRF-guarded, same guard chain as base.py cache_*_from_url) and route
  through cache_media_bytes
- direct-URL non-image attachments: same fetch + classify path
- skip Teams' text/html message-body mirror and adaptive-card attachments
- DOCUMENT > PHOTO > VIDEO > AUDIO precedence for mixed attachments,
  matching the Email precedence rationale from #44695
2026-06-12 02:05:41 -07:00
Teknium
74180ebf0b fix(gateway): classify SimpleX non-image/non-audio files as DOCUMENT
SimpleX tagged unknown files application/octet-stream in media_types
but classification only handled audio/image, leaving msg_type TEXT —
run.py never injected the document context. Same bug class as #12845.
2026-06-12 01:07:50 -07:00
underthestars-zhy
b4e95a2efe fix(photon): add clarifying comments for Windows-safe os.kill usage 2026-06-12 01:07:38 -07:00
underthestars-zhy
23305cfeab fix(photon): normalize DM chat keys in last-inbound reaction tracker
Inbound events key the tracker by the DM chat GUID (any;-;+1555...),
but home-channel react calls address the same space by bare E.164 —
normalize both to the phone so add_reaction's last-inbound default
resolves regardless of which form the caller uses (mirrors the
sidecar's phoneTargetFromSpaceId).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy
156f4fba92 feat(photon): add agent-facing emoji reaction support
Add `action='react'` to `send_message` tool and expose `add_reaction`/
`remove_reaction` on the Photon adapter.

- Track latest inbound message id per chat (`_last_inbound_by_chat`,
  bounded to 200 entries) so the agent can react without threading
  message ids through tool calls
- New `add_reaction`/`remove_reaction` public methods on PhotonAdapter;
  unlike the lifecycle tapbacks, these are not gated by PHOTON_REACTIONS
- `send_message` gains `action='react'` with `emoji` and optional
  `message_id` params; resolves target via existing channel-directory
  and home-channel logic; requires a live gateway adapter
2026-06-12 01:07:38 -07:00
underthestars-zhy
a23c0b378c fix(photon): use per-call httpx client in _sidecar_call
Prevents "Future attached to a different loop" errors when
_sidecar_call is invoked from a worker thread via _run_async in
send_message_tool. The persistent _http_client remains in use for
the inbound streaming loop, which always runs on the gateway's loop.
2026-06-12 01:07:38 -07:00
underthestars-zhy
9bfff6e16c chore(photon): bump spectrum-ts to 3.1.0 2026-06-12 01:07:38 -07:00
underthestars-zhy
a652131c42 fix(photon): stop gateway restarts from orphaning the sidecar on its port
A hard gateway exit (crash, SIGKILL, supervisor restart) left the
detached Node sidecar running with a token the next gateway run doesn't
know, so it could never be told to /shutdown. Every replacement spawn
then died on EADDRINUSE, failing each 30→300s reconnect attempt while
the orphan kept consuming the inbound gRPC stream.

Two layers:
- Lifetime binding: the adapter now holds the sidecar's stdin as a
  pipe, and the sidecar (PHOTON_SIDECAR_WATCH_STDIN=1) shuts down on
  stdin EOF — fired by the OS on any parent death, including SIGKILL.
- Startup reaping: before spawning, the adapter probes the port and
  terminates a stale listener, but only after verifying its command
  line is a Photon sidecar; a foreign listener raises a clear error
  instead of being signalled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy
573c4e6511 feat(photon): upgrade to spectrum-ts 3.0.0 (pinned) with markdown + reactions
Pin spectrum-ts to exactly 3.0.0 (was ^1.18.0 plus an `npm install
spectrum-ts@latest` on every setup) so breaking SDK majors can't take
down fresh installs silently; `hermes photon setup` now runs `npm ci`.
Upgrade procedure documented in the README.

Migrate resolveSpace to the v3 namespace API: `im.space.create(phone)`
for DMs and `im.space.get(id)` for everything else — group spaces are
now rehydratable from their persisted id after a sidecar restart, which
v1 could not do.

Markdown: replies go out via the v3 `markdown()` builder (iMessage
renders natively; other Spectrum platforms degrade to plain text).
`PHOTON_MARKDOWN=false` reverts to the stripped plain-text path.

Reactions, behind PHOTON_REACTIONS (default off): lifecycle tapbacks
(👀 while processing, 👍/👎 on completion) via new sidecar /react and
/unreact endpoints with per-target reaction-handle tracking, and user
tapbacks on bot-sent messages routed to the agent as synthetic
`reaction:added:<emoji>` events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy
0a963d8c9a feat(photon): add telemetry toggle via hermes photon telemetry 2026-06-12 01:07:38 -07:00
Austin Pickett
c3464ecf45
fix(discord): recover from runtime gateway task exits (#44383)
* fix(discord): recover from runtime gateway task exits

Salvaged from #39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.

A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.

Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).

3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.

Co-Authored-By: ameobius <ameobius@local.host>

* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test

connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:39:01 -04:00
teknium1
08b1c44a53 fix(discord): extend bot-task cancellation to connect()'s generic exception branch
Follow-up to #44389: the generic 'except Exception' branch in connect()
had the same orphaned-task hazard as the timeout branch. Extract the
cancel-and-await logic into _cancel_bot_task() and call it from all
three sites (timeout branch, exception branch, disconnect()).

Also adds deaneeth to AUTHOR_MAP.
2026-06-11 12:09:18 -07:00
Dineth Hettiarachchi
020ef76cf1 fix(discord): cancel _bot_task on connect() timeout to prevent zombie client
When connect() times out waiting for the Discord ready event, the background
asyncio.Task running client.start() was not cancelled. discord.py's internal
reconnect loop can ignore client.close() while a WebSocket handshake is in
flight, so the orphaned task eventually completes and fires on_ready.

A later successful reconnect then leaves two live Discord clients in the same
process — each with its own on_message handler and MessageDeduplicator instance
— so every @mention creates two threads because the per-adapter dedup caches
cannot catch cross-client duplicates.

Fix: explicitly cancel and await _bot_task in two places:
1. The asyncio.TimeoutError handler inside connect() — catches the case where
   the adapter's own inner wait_for fires before the gateway's outer timeout.
2. The start of disconnect() — the load-bearing path, always reached via
   _dispose_unused_adapter regardless of which timeout fired first.

Root cause confirmed from production logs: a Jun 8 network outage caused three
consecutive connect() timeouts. The first attempt's bot_task completed its
handshake 4 minutes later ("Connected as") with no preceding watcher line,
then the watcher's real reconnect also connected 90 seconds after that. The two
clients ran continuously for 41+ hours, confirmed by the same user message
appearing as two separate inbound events in two different thread IDs 357ms apart.

Regression tests added to tests/gateway/test_discord_connect.py:
- test_connect_timeout_cancels_bot_task: simulates a connect() timeout with a
  NeverReadyBot and asserts _bot_task is None afterward
- test_disconnect_cancels_running_bot_task: injects a live zombie task, calls
  disconnect(), and asserts the task is cancelled and the attribute cleared
2026-06-11 12:09:18 -07:00
Teknium
243cada157 fix(model): cover typed gateway /model path + async-safe pricing lookups
Follow-ups on top of #26016's expensive-model guard:

- gateway/slash_commands.py: typed '/model <name>' now routes through the
  expensive-model confirmation gate (slash-confirm buttons / text fallback)
  instead of bypassing the guard the pickers enforce. Cancel leaves the
  session override and --global config untouched.
- telegram/discord/web_server: run expensive_model_warning() via
  asyncio.to_thread — it can hit models.dev or a /models endpoint on a
  cache miss, which would otherwise block the event loop.
- telegram: picker callback no longer toasts 'Model switched!' when the
  switch callback raised (both mm: and mc: paths).
- tests: new tests/gateway/test_model_command_expensive_confirm.py pins
  the typed-path gate (prompt, confirm-once, cancel, cheap-model no-op).
2026-06-10 00:24:06 -07:00
Robin Fernandes
af978ecb17 fix(model): require confirmation for expensive model selections
Rebased onto current main and re-ported across the restructured
surfaces: model flows now thread confirm_provider/base_url/api_key
through hermes_cli/model_setup_flows.py, the Discord picker lives in
plugins/platforms/discord/adapter.py, and the web dashboard picker
applies chat-mode switches via config.set so the expensive-model
confirmation can ride the response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:24:06 -07:00
Joel Chan
e5580f43c2 fix(discord): propagate role_authorized flag so DISCORD_ALLOWED_ROLES works end-to-end
DISCORD_ALLOWED_ROLES was checked by the Discord adapter (_is_allowed_user)
but gateway._is_user_authorized only read DISCORD_ALLOWED_USERS, so
role-authorized users were rejected with "Unauthorized user" at the
gateway layer despite passing the adapter gate.

- Add role_authorized: bool = False to SessionSource
- Add role_authorized param to build_source (base.py)
- Compute _role_authorized in on_message when user passes via role not user ID
- Thread _role_authorized through _handle_message -> build_source
- Check source.role_authorized early in _is_user_authorized (run.py)

Fixes #33952
2026-06-10 00:18:11 -07:00
xxxigm
311900842e fix(discord): don't auto-disconnect voice when reply mode is off
The voice inactivity timer (VOICE_TIMEOUT) only counted the bot's OWN audio
playback as activity. Under /voice off (text-only replies, but still in the
channel — leaving is /voice leave) nothing ever reset it, so every 300s the bot
disconnected and spammed "Left voice channel (inactivity timeout)."

The adapter now learns the live voice-reply mode via a getter wired from run.py
and skips the auto-disconnect while mode is off. It also resets the timer when a
user actually speaks to the bot, so an active listener (incl. voice-on
text-only sessions that never play audio) isn't dropped mid-conversation.
2026-06-09 23:24:26 -07:00
Philip D'Souza
92dfd70d6a
fix(photon): production hardening for the gRPC-native iMessage channel (#42732)
* fix(photon): override transitive CVEs in the sidecar deps

`npm audit` flagged 7 high-severity transitive CVEs (protobufjs code injection
GHSA-66ff-xgx4-vchm + outdated @opentelemetry OTLP exporters) pulled in via
spectrum-ts -> @photon-ai/otel. npm's suggested fix downgrades spectrum-ts to a
version that targets the decommissioned spectrum host, so instead pin patched
versions via `overrides` (protobufjs 8.6.1, @opentelemetry/* 0.218.0) without
touching spectrum-ts. `npm audit` -> 0; spectrum-ts + provider still import.

* fix(photon): harden the sidecar bridge + bound the dedup cache

- constant-time sidecar control-token comparison (was `!==`, timing-attackable).
- cap the control-channel request body (2 MiB) so a compromised local peer can't
  OOM the sidecar.
- wrap the inbound gRPC stream consumer in a re-subscribe loop with capped
  exponential backoff + jitter — if the async iterator throws/ends it would
  otherwise stop inbound forever (the adapter dedupes any replay).
- add an unhandledRejection handler so a stray rejection logs instead of killing
  the process.
- dedup cache (adapter) was a true bounded LRU only for expired entries; a burst
  of unique ids within the window grew it without limit. Evict oldest at the cap.

* chore: add AUTHOR_MAP entry for PhilipAD

---------

Co-authored-by: PhilipAD <philipadsouza@gmail.com>
2026-06-09 11:12:58 -04:00
underthestars-zhy
dbf2470d46 feat(photon): Add voice message support to Photon adapter
Extend the sidecar and Python adapter to handle `voice` content
alongside `attachment`. Voice notes are inlined as base64 (same
size-cap logic), surfaced as `MessageType.VOICE`, and include an
optional `duration` field in fallback markers when bytes are
unavailable.
2026-06-08 22:53:01 -07:00
underthestars-zhy
9fb83eaa2f fix(photon): bump spectrum-ts to ^1.18.0 and always install latest on
setup
2026-06-08 22:53:01 -07:00
underthestars-zhy
0337658904 fix(photon): migrate user API calls to Spectrum backend
Switch `list_users`, `find_user_by_phone`, `create_user`,
`register_user_if_absent`, and `refresh_user_numbers` from the
Dashboard API (Bearer token) to the Spectrum API (Basic auth with
project credentials). Update response unwrapping to handle the nested
`data.users` envelope returned by Spectrum, add `_spectrum_host()`
resolver, `_basic()` header helper, and structured error helpers.
Update tests, docs, and plugin.yaml accordingly.
2026-06-08 22:53:01 -07:00
underthestars-zhy
b58ff93459 feat(photon): persist and display user phone numbers in status
Store operator and assigned iMessage numbers in `auth.json` after
setup, and surface them in `hermes photon status`. When numbers are
missing, status auto-refreshes from the dashboard without provisioning
new lines.
2026-06-08 22:53:01 -07:00
underthestars-zhy
2130ef68b3 fix(photon): Enable group flattening in Spectrum config 2026-06-08 22:53:01 -07:00
underthestars-zhy
637cf94bed fix(photon): strip markdown and add send retry logic 2026-06-08 22:53:01 -07:00
m4dni5
d1f23bb2d5 fix: prevent TUI gateway stdin EOF crash across all TUI-context subprocess calls
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this stdin fd can trigger a race condition where the child's stdin
read returns EOF, causing the gateway to exit cleanly (exit code 0) mid-tool-
execution.

This is the same root cause as issue #14036 (byterover plugin) and PR #39257
(SSH environment backend). This commit applies the fix — stdin=subprocess.DEVNULL
— to all 85 subprocess.run() and subprocess.Popen() calls that execute inside
the TUI gateway child process.

Scope: TUI-context code only (agent/, tools/, plugins/, tui_gateway/server.py).
CLI code (cli.py, hermes_cli/), tests, scripts, and gateway process management
are excluded — they don't run inside the TUI child and inherit the terminal's
stdin, not the JSON-RPC pipe.

85 call sites across 28 files. All files pass syntax check.
2026-06-08 22:46:57 -07:00
underthestars-zhy
3b983e7791 fix(photon): add home channel env seed and simplify space resolution 2026-06-08 21:03:58 -07:00
underthestars-zhy
0d25cae041 fix(photon): remove reply-to support and fix typing API
Drop `replyTo` from all outbound send paths and update the `/typing`
endpoint to use the documented `typing("start" | "stop")` content
builder. Adds a `stop_typing` method on the adapter to pair with
`send_typing`.
2026-06-08 21:03:58 -07:00
underthestars-zhy
e79e44af79 fix(photon): use spectrum-ts reply builder for threaded messages
Replace raw `{ replyTo }` send options with the `spectrumReply` content
builder from spectrum-ts, which is the correct API for threading
replies.
Adds `maybeReplyContent` helper with graceful fallback to normal send
when
the reply target cannot be resolved.
2026-06-08 21:03:58 -07:00
underthestars-zhy
fdf48c63c8 fix(photon): wrap text sends with spectrumText helper 2026-06-08 21:03:58 -07:00
underthestars-zhy
0646656884 fix(photon): support E.164 and DM GUID targets for home channel
Allow PHOTON_HOME_CHANNEL to accept a bare E.164 phone number or a
`any;-;+1...` DM chat GUID in addition to a Spectrum space id. Inbound
DM spaces are cached so replies resolve without a second SDK lookup,
and `photon` is added to _PHONE_PLATFORMS so send_message treats E.164
strings as explicit targets rather than falling through to channel-name
resolution.
2026-06-08 21:03:58 -07:00
underthestars-zhy
92179352fb feat(photon): auto-configure allowlist and cron channel on setup
During `hermes photon setup`, allowlist the operator's number and set
their DM as the cron home channel when those env vars are unset. Without
this, the gateway denies the operator's own messages and cron has no
default delivery target. Re-runs never overwrite hand-tuned values.

Also teaches the sidecar's `resolveSpace` to accept a bare E.164 number
as a space identifier, resolving it to the user's DM space so
`PHOTON_HOME_CHANNEL` can be set to a phone number instead of an opaque
space id.
2026-06-08 21:03:58 -07:00
underthestars-zhy
e9b26c7c8b style(photon): Colorize iMessage number box in setup output 2026-06-08 21:03:58 -07:00
underthestars-zhy
84e4b4b9a5 fix(photon): use per-user assigned line for agent iMessage number
On shared-number plans, `/lines` has no dedicated entry, so the
`assignedPhoneNumber` field on the user object is the source of truth
for which number to text the agent. Fall back to the line inventory
only when no per-user assignment exists.
2026-06-08 21:03:58 -07:00