Commit Graph

5327 Commits

Author SHA1 Message Date
teknium1
e5df4320f6 fix(gateway): /fast and its Ultrafast gate judge the session's own route
/fast now asks the same question the next turn asks: resolve_fast_mode_overrides
over the route _resolve_session_agent_runtime returns (session /model override,
channel override, persisted override after a restart), with provider and
base_url. Both the Priority gate and the Ultrafast gate added in #128307 read
that one route, so GPT-6 Astra picked with session /model on openai-codex gets
/fast and /fast ultrafast, and a fast-capable default no longer admits a
session route whose requests never carry the tier (gpt-5.4 via OpenRouter).

The tier override is also stored under the topic-recovered session key, the
one the next turn reads (#30479 class; /model and /reasoning already do this),
so on Telegram DM topics a session /fast is no longer dropped.

Route resolution runs off the event loop (it can refresh an OAuth token, as
the turn does); with no usable route at all the configured model is judged
alone, as before.

Tests: the four salvaged cases collapse into two invariants on the real
fast-mode tables (both gate directions + the turn's request_overrides, and
the recovered-topic key); red on origin/main.
2026-10-01 22:47:04 -07:00
Willhong
cd2c5cf9af fix(gateway): resolve /fast eligibility against session model override
Route /fast eligibility through _resolve_session_agent_runtime (the same
resolver normal turns use) instead of the global _resolve_gateway_model,
so a session-scoped /model override or channel override keeps /fast in
sync with the model the next agent turn actually routes to. Normalize the
command source exactly like /model does so the override is read under the
key it was stored, and let the resolver rehydrate persisted overrides a
gateway restart cleared from memory.

Rebased onto current main (was DIRTY): the lazy import in
_handle_fast_command now pulls only _load_gateway_config; the sibling
picker tests in test_choice_picker.py::TestFastChoicePicker are updated to
stub _resolve_runtime_agent_kwargs (matching test_fast_command.py) so the
no-override path doesn't hit real provider resolution in CI.

Tests: tests/gateway/test_fast_command.py (10) +
tests/gateway/test_choice_picker.py::TestFastChoicePicker (4) pass.

(cherry picked from commit 9885267a6f6e124b470fa2f597ef9c3623063549)
2026-10-01 22:47:04 -07:00
unsupportedpastels
5bba024d8d fix(gateway): count restart-wait cron exclusions per profile-scoped run
A scoped or wedged daily-brief in profile A must not hide a degraded or
live daily-brief in profile B. Split the scheduler's in-flight keys before
counting and add awaitable cron runs independently of non-cron work.
Carry the per-run restart_safe flag to the observer and preserve the
existing bare-ID accessors for unrelated callers.

Thanks to @ehz0ah for diagnosing the multiplexed collision and proposing
the profile-keyed accounting and mixed scoped/degraded regression case.
Preserve @Superfly73's source commit separately; map their verified bare
noreply email for the contributor CI gate.

Refs #130987

Co-authored-by: z1gon <130889443+ehz0ah@users.noreply.github.com>
2026-10-01 22:25:21 -04:00
Superfly73
336b557bec fix(gateway): skip cron runs in their own restart-safe scope in the restart wait
A cron run handed to an external worker in its own transient systemd scope
outlives the gateway: the shutdown drain deliberately leaves it unmarked
(``mark_running_jobs_interrupted``) and its final send rides the durable
delivery queue, drained by whichever gateway is live next. The after-turn
restart wait still counted it as active work, so ``/restart``, SIGUSR1 and
``hermes update`` held the gateway in ``draining`` — refusing every new turn —
for up to ``agent.restart_after_turn_timeout`` (30 min by default) while the
worker kept running regardless. Observed in the field: a 100-minute job turned
a restart into a ~30-minute lockout.

- ``cron.scheduler`` records whether an accepted handoff got its own scope
  (``dispatch.mode == "scoped"``) and exposes
  ``get_restart_safe_external_job_ids()``; runs already reported as wedged are
  excluded there too, because the gateway subtracts both counts and a run in
  both sets would be subtracted twice.
- ``_awaitable_work_count()`` subtracts that count as a second, disjoint
  exclusion; ``_describe_active_work()`` marks the unit (``restart_safe``).
- A degraded worker (external, but no reachable user bus, so it shares the
  gateway cgroup) is NOT listed and is still awaited — the restart would kill
  it mid-run.
- The wait and observation log lines now name both exclusion classes instead of
  claiming everything still listed will be interrupted.

Tests: tests/gateway/test_restart_drain.py (skip + control),
tests/cron/test_restart_safe_worker.py (registry contract).
Docs: updating.md restart section, cli-config.yaml.example.

(cherry picked from commit 6a2f0b77cf07a60510430fc4ef8b75539ee422fb)
2026-10-01 22:25:21 -04:00
teknium1
be5e9f72c6 fix(cron): a partly delivered split send is not re-sent by the standalone fallback
The router turned every failed SendResult into a bare RuntimeError, which
dropped raw_response, and the cron live lane falls back to the standalone
sender on any error. A split send that failed after delivering its head
(Telegram, Discord, and now SMS/WeCom) was therefore delivered again from
the start by the standalone lane.

The router raises PartialDeliveryError for a partial_overflow result; the
live lane records "delivered N of M chunks, then failed" on the run and
does not fall back.
2026-10-01 17:18:56 -07:00
teknium1
c8cde58273 fix(gateway): a split send that fails mid-way is never re-sent whole
SMS and both WeCom adapters now split long replies, and their chunk loops
returned a plain failure when a later chunk was rejected. The base send
path cannot tell that from a total failure, so _send_with_retry retried the
whole payload or sent the 3500-char plain-text fallback and reported
success: the recipient got the head twice and never got the tail.

The three loops now share gateway.platforms.helpers.send_chunks(), which
marks a failure after delivered chunks with the existing partial_overflow
contract (the one Telegram and Discord set and _is_partial_delivery reads).
The retry and plain-text fallback already refuse to re-send a partial
delivery, so the caller gets an honest failure and nothing is duplicated.
Resuming the undelivered tail is #124295's scope.
2026-10-01 17:18:56 -07:00
kshitijk4poor
eafbe60bf9 fix(pairing): purge revoked users from live allowlist sets in place
Discord approval views and the VoiceReceiver hold a reference to the
adapter's _allowed_user_ids set. Rebinding the attribute to a new set left
those holders authorizing a revoked user until restart; mutate the set in
place instead. list/str/None snapshots keep the rebinding behaviour.
The revoke test now pins that a held reference loses the revoked user,
and drops a tautological id(adapter) assertion plus an unused fixture.
2026-10-02 02:59:59 +05:30
FroRaut
09c9ed2a61 fix(matrix): apply pairing revocations live
(cherry picked from commit 6c69d87fee43e5bcdcbc5f0e2d26fa9e1aa83ecc)
2026-10-02 02:59:59 +05:30
kshitijk4poor
e5e2d5508f fix(whatsapp): tighten cloud webhook identity binding and its diagnostics
The identity guard skipped non-dict change values with a warning that
claimed an identity mismatch, and the mismatch warning gave operators no
way to tell which number/WABA was arriving versus configured. A shared
Meta app also delivers status receipts for sibling numbers, so warning
on every one is noise: warn only when the foreign change carries
messages, debug otherwise. Malformed values are now skipped silently.

The phone_number_id arm's "not configured" escape was dead (the adapter
cannot start without a phone number id), so compare it directly.

The setup wizard STEP 4 header and module docstring still described the
WABA ID as analytics-only; it now binds inbound webhooks.
2026-10-02 02:58:18 +05:30
FroRaut
4152c69636 fix(whatsapp): bind cloud webhooks to configured identity
(cherry picked from commit a2efc1d62c5be444274ae58fda1aa86d1d9793ee)
2026-10-02 02:58:18 +05:30
kshitijk4poor
ed7909bad9 refactor(webhook): drop dead revision snapshot, lock-free reads, CAS owns the disable rule
The sha256 revision returned by _load_subscriptions_unlocked was never compared by
anyone, so _load_subscriptions_snapshot was dead weight; the loader now returns the
dict and folds its duplicate excepts. _load_subscriptions reads without the store
lock: writers publish by atomic rename, so readers always see one whole snapshot,
and read-only homes (where the lock file cannot be created) keep listing routes.
Nothing re-enters the lock any more, so the thread lock is a plain Lock.

The "keep an explicit enabled: False" rule was copied into both the CLI subscribe
path and the dashboard create path; it now lives in _replace_subscription after the
CAS check (where the current record is authoritative) and the published route is
returned, so the dashboard summary reflects what was actually stored.

The rebind secret selection collapses to one expression (same result for all four
profile-changed x --secret combinations), and the test-only _save_subscriptions is
removed; tests seed through _mutate_subscriptions.

The gateway's dynamic-route reload now records the stat key for a malformed or
non-dict file too, so a bad version is parsed once rather than on every POST while
the last good routes stay live. Docs describe the reload as stat-gated and lock-free.
2026-10-02 02:54:18 +05:30
kshitijk4poor
56845fedc5 fix(webhook): stat-gate dynamic route reload without the writer lock
The salvaged transaction commit made the gateway's _reload_dynamic_routes
take the CLI writer lock (thread RLock + blocking fcntl flock) and read +
sha256 the whole subscriptions file on every POST, before auth, on the
event loop. A CLI/dashboard writer holding the lock stalls the gateway and
unauthenticated callers drive lock acquisitions.

Writers publish via atomic rename, so a lock-free read is already
coherent, and the rename gives the file a new inode. Gate the reload on
(st_mtime_ns, st_size, st_ino) instead: one stat per request, and a
restored/coarse mtime still triggers a reload. An unreadable or non-dict
file keeps the last good routes (the snapshot helper returned an empty
store on read errors, wiping live routes); only a missing file clears
them. Fix the stale "mtime-gated" call-site comment.

Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
2026-10-02 02:54:18 +05:30
FroRaut
3082d00c82 fix(webhook): transact subscription mutations
(cherry picked from commit 8c4ee86733a1193687f379e158e870ee4bcf56b5)
2026-10-02 02:54:18 +05:30
kshitijk4poor
c67b0a1dc4 refactor(gateway): name the media-cache max age and lstat cache files once
The hourly housekeeping loop hard-coded 24 while every cache accessor
defaults to MEDIA_CACHE_MAX_AGE_HOURS; use the constant so the two cannot
drift (docstring too). iter_cache_files did is_symlink + is_file + stat
per item behind an Optional-cutoff helper; one lstat per item with
S_ISREG (symlinks still excluded, unreadable entries skipped) and a
hoisted generated-root string give identical results with fewer syscalls.
2026-10-02 01:18:03 +05:30
kshitijk4poor
266499524e fix(gateway): deliver and bound-sync generated media in cache/generated
The salvage moved generated images/videos to cache/generated/<kind>/ so the
24h sweep stops deleting the only copy (#126445), but two consumers still
only knew the old swept dirs:

- Strict media delivery (HERMES_MEDIA_DELIVERY_STRICT=1) allowlists cache
  roots via _MEDIA_DELIVERY_CACHE_SUBDIRS; without "generated" a day-old
  generated image was rejected once the recency window lapsed, i.e. the
  move broke re-sending artifacts on public gateways.
- iter_cache_files (Modal/remote resync) walked the new never-swept tree in
  full, so every sync re-uploaded the whole generation history. Only files
  within the sweep window (MEDIA_CACHE_MAX_AGE_HOURS) are synced now.

GENERATED_SUBDIR and MEDIA_CACHE_MAX_AGE_HOURS live in agent/provider_media
as the single source for the image/video writers, the gateway allowlist and
cleanup default, and credential_files. Also trims duplicated comments and
fixes the pet orchestrate comments (nothing prunes cache/generated/images).
Tests use direct function refs instead of importlib strings.
2026-10-02 01:18:03 +05:30
kshitijk4poor
a10b0d4cae refactor(gateway): drop unreachable refusal in standalone lock-loss path
The block is entered only when profile_is_standalone(home) is True, and
standalone_attach_decision returns None only when that same check is False,
so the trailing _refuse_second_host_gateway(owner) could never run. The
function's final refusal still covers every non-standalone path.
2026-10-02 01:17:03 +05:30
JoaoMarcos44
42fb720c73 fix(gateway): stabilize host rendezvous process identity
(cherry picked from commit 3a9043b3dc8d6602a876a97f1b6da18d4812be50)
2026-10-02 01:17:03 +05:30
Yuan Li
ff6122e6f8 fix(gateway): allow standalone starts when owner probe is unavailable
(cherry picked from commit 7167be14e488edf977aa8ea7060d9f2ae9391371)
2026-10-02 01:17:03 +05:30
kshitijk4poor
804239bc27 fix(gateway): hold /loop wakeups while hermes is paused
/loop wakeups are injected with internal=True, and _hm_estop_gate returns early
for internal events, so `hermes pause` did not stop loop ticks from starting
agent turns. This is the same problem the heartbeat poller fix handles.

Check the emergency stop before LoopManager.fire_tick. is_due() is read-only and
fire_tick() is what moves next_due_at forward, so the tick stays due and fires on
the first scan after `hermes resume`. The watcher sleeps for its interval (15s)
after every scan, so being paused does not make it busy-spin. check_paused logs
once per engagement.
2026-10-02 01:13:21 +05:30
kshitijk4poor
ff244960a9 refactor(gateway): clarify why the estop gate drops stray heartbeat turns
The poller now checks `hermes pause` before claiming a heartbeat tick, so the
inbound gate only sees heartbeats that slipped past that check. Say so, and
point at settle_heartbeat_attempt as what keeps the tick due for after resume.
2026-10-02 01:13:21 +05:30
Dzianis Vashchuk
faeb7eecc9 fix(gateway): heartbeats respect hermes pause instead of spamming the paused notice
While ESTOP was engaged the heartbeat poller kept injecting the due tick every
5s; the estop gate answered each one with the user-facing paused notice and the
refunded tick stayed due, so the notice was re-sent every poll (~10k Telegram
messages in 5h, twice in prod).

- _heartbeat_poll_watch: skip before claiming the tick while paused (logged once
  per engagement via check_paused); it fires normally after hermes resume.
- _hm_estop_gate: a synthetic heartbeat event is dropped silently; real user
  messages still get the paused notice.

(cherry picked from commit 74c04121e35e4479f59dd522281298a7142ea951)
2026-10-02 01:13:21 +05:30
kshitijk4poor
d3a5d51556 fix(gateway): use a random fallback id for header-less webhook deliveries
Header-less POSTs fell back to a millisecond timestamp as their delivery
id. Now that the id is the dedup key within a route, two distinct
header-less deliveries to the same route in the same millisecond were
treated as a retry and the second was dropped as "duplicate". A uuid4
keeps each header-less delivery unique; providers that send a delivery
header still dedup on it.
2026-10-02 01:07:31 +05:30
FroRaut
30f556d094 fix(gateway): scope webhook delivery identities
(cherry picked from commit 798dc009df19d5fa5090cb497d6f62590ab2a2d7)
2026-10-02 01:07:31 +05:30
Victor Kyriazakos
d6cdebda33 fix(relay): chunk long cron output through the relay connector instead of truncating it
RelayAdapter now advertises the connector egress splitter through splits_long_messages, allowing cron delivery to preserve the full payload while retaining the gateway audit save. The connector applies splitForEgress against CapabilityDescriptor.max_message_length for gateway-originated send operations; no adapter-side chunking is needed. This follows delivery cap 86e4521cb1 and the adapter flag sweep e9cd8c5bf3.
2026-10-01 12:27:27 -07:00
kshitijk4poor
99f1b9afe2 fix(gateway): reap cgroup PIDs from a fresh read taken after the live-gateway guard
The single cgroup.procs read now feeds only the empty-check and the
live-gateway guard. The guard can take seconds (gateway.status import,
per-PID cmdline with psutil/ps fallback), so killing from that stale list
could SIGKILL a PID that exited and was reused outside the cgroup, and
would miss orphans spawned in the gap. Re-read cgroup.procs for the kill
loop (one small sysfs read), restoring base's kill-set freshness while
keeping the skipped import on the common empty-cgroup stop.
2026-10-02 00:46:21 +05:30
kshitijk4poor
0687c5e171 refactor(gateway): read cgroup pids once and skip the gateway.status import when empty
reap_cgroup now reads cgroup.procs once and filters out its own PID. When
nothing else is left it returns before importing gateway.status, which saves
~0.6s on each ExecStopPost that has no orphans to clear. The live-gateway
guard and the kill loop both use that same snapshot, so the guard checks
exactly the PIDs that would otherwise be signalled.

A refusal now returns None instead of the -1 sentinel, so it can't be mixed
up with a kill count; main() checks `is None`. The incident-history text is
trimmed from the _parent_is_systemd docstring.

Tests drop from 3 to 2: the orphans-only allow-path moves into
test_reap_refuses_when_live_gateway_in_cgroup, and the assert on the private
_parent_is_systemd() is removed (main() == 1 already covers it).
2026-10-02 00:46:21 +05:30
kshitijk4poor
cc963778f1 fix(gateway): exit non-zero when cgroup_cleanup refuses a live-gateway reap
main() returned 0 when reap_cgroup() refused because a live gateway was
still in the cgroup, so a manual caller could not tell a refusal from a
clean reap. reap_cgroup() now returns -1 on refusal and main() exits 1.

The only in-tree caller is the generated unit's ExecStopPost, which carries
the '-' prefix (asserted in tests/gateway/test_systemd_stop_mark.py, present
since the reaper was introduced), so a non-zero exit cannot fail a unit stop.

Fixes #126845

Co-authored-by: jcjc81 <jcjc81@users.noreply.github.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-10-02 00:46:21 +05:30
jcjc81
c45ff644f0 fix(gateway): use the runtime (run|restart) matcher in the cgroup live-gateway guard
(cherry picked from commit c8d95f41aeea3a0198404fe4580bef18e6b62749)
2026-10-02 00:46:21 +05:30
jcjc81
34e3cd0e54 fix(gateway): drop PID-1 trust in cgroup_cleanup; refuse reap when a live gateway is still in the cgroup
(cherry picked from commit 07f7728beee1a5b47a26aa29729af6a56ad9b0a4)
2026-10-02 00:46:21 +05:30
jcjc81
33dd6658d1 fix(gateway): refuse cgroup_cleanup reaping under a non-systemd parent (prevents live-gateway self-kill)
(cherry picked from commit db4dd8f266c7b10b7eff903bb217aa52fc662295)
2026-10-02 00:46:21 +05:30
kshitijk4poor
aeaeba74b7 fix(cron): a routed profile's cron delivery no longer rewrites the launch profile's media policy
Under a multi-profile host (multiplex, or the desktop backend's per-profile cron ticker), a
routed profile B's cron delivery calls apply_media_policy_env(B's config) under B's
HERMES_HOME override. The bridge writes B's gateway.strict / media_delivery_allow_dirs /
trust_recent_files into the SHARED os.environ whenever the launch config leaves them unset.
From then on the launch profile's own unbound media validation, and every launch-profile
child built from os.environ (bot relay delivery, slash worker), use B's allowlist and
strictness. B's own readers already take B's config under the override
(_routed_gateway_cfg, 819517fbac), so the write bought nothing.

Skip the bridge under a HERMES_HOME override - the exact gate the readers use: there they
take the profile's own config, so an env write is never needed and only leaks. Every cron
fire binds an override, so the leak is closed for every routed fire. Gated on the override
rather than serves_routed_profile(): that predicate is True for the whole process once it
multiplexes, which would also skip the launch profile's own override-less bridge (a
serve/desktop backend importing gateway.run after activation). Single-profile
`hermes cron run` / `hermes send` and gateway boot keep the bridge.

Introduced by 22f0f22298 (Victor Kyriazakos, 2026-08-17, #88631), which added the bridge to
cron delivery; 819517fbac (Teknium, 2026-09-11) made the readers routed-aware but left the
writer unguarded. Found while following up the env-leak note on #120945 / #129575.
2026-10-01 22:59:37 +05:30
kshitijk4poor
2e7c3322a4 test(gateway): prove the unreadable-row negative actually reads the row
The case passed even with the row read removed, since an unmarked entry
attaches no callback either way. Record the failing get_session call and
assert it ran once, so the case proves a broken session DB is reached and
contained. Also split a packed tuple assignment in the fallback.
2026-10-01 18:07:32 +05:30
kshitijk4poor
cc1e4e904c fix(discord): fall back to the session row whenever the routing entry lacks the markers
Gate findings on the routing-entry recovery:

- Trigger the row read on what recovery needs (the entry's origin is not this
  thread's auto-thread opening) instead of a message-id match. An entry rebuilt
  by an earlier event, or by an event without a message id, now still recovers
  from the row, as main did; auto-created threads keep skipping the DB.
- Read the row from SessionStore._db_for_key(session_key), the store that owns
  the key's rows whatever profile scope is active, not the ambient runner DB.
- Replace suppress(Exception) with explicit row checks plus a narrow guard on
  parsing; a failing DB read reaches the caller's logged handler.
- Restore the str() thread-id comparison the merged code used.
- Negative test covers an unreadable session row.
2026-10-01 18:07:32 +05:30
kshitijk4poor
5843109310 refactor(discord): recover auto-thread markers from the routing entry in run_topics
Follow-up to the Discord title-retry rename fix (ee559ccb3b, salvage of
#127673 by @anpicasso), which re-read SessionDB.get_session inline in
TurnRunner on every native in-thread Discord turn.

- Move the recovery to GatewayTopicThreadsMixin._recover_discord_auto_thread_source
  beside _is_discord_auto_thread_lane; run_turn_runner.py returns to its pre-fix size at the
  2,000-line cap instead of carrying Discord lane logic.
- Read the opening source from the routing entry (SessionEntry.origin, already a
  parsed SessionSource that survives restarts) instead of get_session per turn:
  that call drains queued token counts (up to a 5 s wait) and joins the full
  system prompt just to read origin_json.
- Keep the row read only for an entry rebuilt from this very event (lost routing
  index), where the entry holds the unmarked live source while the row still has
  the opening origin, so no case that recovered before stops recovering.
- Tests: the recovery contract (routing entry and rebuilt-entry fallback, keeping
  the live message id and transport owner) plus the negative contract (another
  thread or a user-created thread never lends its markers), through the real
  predicate and recovery.
2026-10-01 18:07:32 +05:30
funkyotc
6f25660a6b fix(init): target the session's active directory, not the process cwd
/init resolved its project directory via os.getcwd() — the process
launch directory — on all three dispatch surfaces (CLI, gateway,
TUI/desktop). On the desktop app the process launches from the home
directory, so /init scanned the HOME and merge-updated the home
directory's AGENTS.md even though the session was attached to a
project workspace (its terminal commands ran in the right directory).

Resolve the session's ACTIVE directory instead: the terminal tool's
per-session cwd record (seeded when a workspace attaches, updated
after every cd) first, then resolve_agent_cwd()'s canonical ladder
(session contextvar -> TERMINAL_CWD -> process cwd). Dispatch sites
pass the session key so multi-session hosts consult the right record.

The stale-record guard lives in build_init_prompt_for_cwd: a cwd that
names a deleted directory (removed project, linked worktree) falls
through to the session ladder on every surface instead of each
dispatch site validating its own record. Without it the TUI/desktop
path handed a truthy-but-dead record straight through as cwd, so /init
targeted a directory that no longer existed — write_file would have
recreated it and written a fresh AGENTS.md into it.

Regression tests cover: session record wins over the process cwd,
stale-record fallback, explicit cwd precedence, ambient session key,
a stale explicit cwd falling through to the session record, and the
TUI/desktop command.dispatch path end-to-end (live record, and a
stale record falling through to the session's attached workspace).

Fixes #96376
2026-10-01 04:50:26 -05:00
Brooklyn Nicholson
6c3aae398a fix(security): harden at-rest secrets in browser profiles, media caches, and the in-flight journal
Chromium debug profile, media-cache and vision/computer-use cache dirs were
created with bare makedirs inheriting the umask (0755); their files landed
0644. Under the documented HERMES_HOME_MODE=0701 traversal hatch those stores
(Cookies, Login Data, screenshots, inbound media) were readable by every
other local account. Dirs are now created owner-only (0700) with the mode
passed to mkdir (no post-chmod window) and reconciled via the house
hermes_cli.config._secure_dir / _secure_file policy helpers, which keep the
managed/NixOS group-share carve-out and HERMES_UID/HERMES_GID ownership.
Cache files are written 0600 via os.open.

Deleting a session also left its journaled in-flight turn tail (user prompt
+ tool calls) in localStorage to age out on its own; purgeInFlightTurnJournals
now runs on both delete surfaces (sidebar removeSession and Settings
permanent delete), draining every id the delete holds.

Refs #77486
2026-10-01 01:50:16 -05:00
worlldz
b9cb5a10bc fix(desktop): keep delegate children out of sessions 2026-10-01 01:37:18 -05:00
StanleyStetson
2ce63fc248 fix(desktop): sort imports to satisfy perfectionist lint rule 2026-10-01 00:47:44 -05:00
StanleyStetson
4b78815cb5 fix(gateway): anchor attachment-ref strip and allow @ in paths
- Anchor _ATTACHMENT_REF_RE to ^ so ref tags are only stripped from
  the message prefix, not from the middle of command arguments.
  Fixes a regression where /status check @url:https://example.com
  lost its @url: argument.

- Allow @ inside the ref path ([^\n]+ instead of [^\n@]+) so
  Windows paths containing @ (e.g. C:\Users\john@doe\img.png)
  are fully stripped and the slash command is detected.

- Add tests for both edge cases.

Co-authored-by: AI triage <noreply@github.com>
2026-10-01 00:47:44 -05:00
StanleyStetson
1fa92e6cb5 fix(desktop): warn user when slash command sent with attachment
Slash commands typed in the Desktop composer were silently ignored
whenever an image or file attachment was present. The !attachments.length
gate in all three submit paths (idle, busy, tile) bypassed
executeSlashCommand, causing the turn to run on the base model with no
user-visible feedback.

Changes:
- use-prompt-actions/index.ts: restructure the slash guard so that when
  SLASH_COMMAND_RE matches but attachments are present, a 'warning'
  toast is shown instead of silently falling through to submitPromptText.
- use-composer-submit.ts: same guard for the busy-path slash check; also
  adds useI18n + notify imports (previously absent from this hook).
- session-tile-actions.ts: same guard for the tile submit path; expands
  import from notifyError-only to notify + notifyError.
- i18n (types.ts, en/ar/ja/zh/zh-hant): two new keys
  slashCommandIgnoredTitle / slashCommandIgnoredBody for the warning.
- gateway/platforms/base.py: defensive hardening -- extract private
  _command_text() helper that strips @image:/@file:/@url: Desktop
  attachment-ref prefixes before slash detection; use it in
  is_command(), get_command(), and get_command_args() for consistency.
  Add module-level _ATTACHMENT_REF_RE constant.
- tests/gateway/test_platform_base.py: 6 new tests covering is_command
  and get_command with attachment-ref prefixes (5 + 1).

Fixes #81798
Related: #79765 (CLI file-drop / slash-command family)
2026-10-01 00:47:44 -05:00
unsupportedpastels
663362680b fix(gateway): pass restored managed pins through plugin inputs 2026-09-30 20:10:03 -04:00
unsupportedpastels
6f7cc09b56 fix(gateway): retain unpinned managed-extra siblings at destination 2026-09-30 20:10:03 -04:00
unsupportedpastels
9c2d40d60a fix(gateway): preserve managed values and bridge authored subdicts
Restore managed extra values after cross-shape merges and expose authored subdict conflicts to plugin hooks before global env fallbacks. Consolidate the precedence coverage into two invariant matrices and qualify documentation for managed policy and explicit env precedence.

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-09-30 20:10:03 -04:00
unsupportedpastels
77bc57df41 fix(gateway): a managed platform-block pin is not overridden by user-authored extra
With authored platforms.<plat>.extra outranking the <plat>: block, a user
could replace a key the administrator pinned in the managed block
(require_mention, allow_from, ...) by writing it under their own
platforms.<plat>.extra. On main the managed block value wins.

Drop managed block-pinned keys from the authored snapshot. A key the
managed layer sets under platforms.<plat>.extra stays authored, so it now
also beats a user's top-level block.
2026-09-30 20:10:03 -04:00
unsupportedpastels
9b553c1267 fix(gateway): legacy gateway.json extra does not outrank the config.yaml platform block
The authored-extra snapshot was taken from the merged platforms_data, which
already holds the legacy gateway.json layer. A gateway.json
platforms.<plat>.extra key was therefore treated as authored and beat the
same key in the config.yaml <plat>: block, in extra and on the env rung,
with a warning naming a platforms.<plat>.extra the user never wrote.
config.yaml keys always win over gateway.json.

Snapshot from a scratch merge of the YAML layer only.
2026-09-30 20:10:03 -04:00
Victor Kyriazakos
de63c6d275 fix(gateway): authored platforms.<plat>.extra wins over the top-level platform block
Replace _fill_extra with a _authored_wins overlay applied at BOTH copy
sites: the shared-key bridge and the plugin YAML hook. The hook now runs
on the overlaid block, so its YAML→env bridge seeds the authored nested
value into the env rung that env-first adapter readers resolve first.
Conflicting (platform, key) pairs warn once per load via a per-load set
threaded through both functions; _fill_extra and the module-level dedupe
set are gone. Docs note the precedence in slack.md.

(cherry picked from commit 39d047224ee7a6987d7b20ef92c8e707d29f9c70)
2026-09-30 20:10:03 -04:00
kshitijk4poor
d3a1e822ab refactor(gateway): reuse the lane predicate for Discord origin recovery
The recovered-origin check re-implemented
GatewayRunner._is_discord_auto_thread_lane field-by-field on the raw
origin dict, a second copy of the lane definition that would drift.
Build SessionSource.from_dict(origin) and gate on the real predicate
plus the same-thread-id match, so the recovery stays consistent with
the lane it feeds. from_dict tolerates malformed rows by raising inside
the existing suppress, same as the inline JSON walk it replaces.

Tests: the fake runner now binds the REAL predicate (the hand-written
lambda could pass while production drifted), plus negative cases for
wrong thread id / manual user thread / blank origin, a broken-DB
degradation case, and a transport-owner preservation case for the
recovered source.
2026-09-30 21:04:34 +05:30
Angello Picasso
ee559ccb3b fix(discord): preserve title retry thread rename
Fixes #127667.
2026-09-30 21:04:34 +05:30
xiaodu
91617521c6 fix(gateway): stay alive on all-adapters-down when no service manager supervises
A gateway whose last messaging adapter hits a retryable fatal error exits
with the failure verdict (EX_TEMPFAIL) so a supervising service manager
restarts it. The desktop app launches `hermes serve` directly with no
supervisor: the exit only severs the UI's websocket connections and drops
in-flight assistant messages, which then 'disappear' from chat (#118080 —
334 fatal events over 6 days on one profile, mostly email IMAP fetch
failures marked retryable).

Add gateway.on_all_adapters_down: exit | stay_alive (default exit — zero
behavior change for service-managed deployments). At both stranded-exit
sites in gateway/run_adapters.py, the stay_alive policy keeps the process
running (reconnect watcher owns recovery) instead of setting
_exit_with_failure; non-retryable adapter loss still exits. The desktop
launcher stamps GATEWAY_ON_ALL_ADAPTERS_DOWN=stay_alive last into the
child env (same rule as HERMES_GUEST_ONBOARDING); older runtimes ignore
the unknown variable and keep the historical behavior.

Fixes #118080
2026-09-29 19:33:09 -05:00
Ritvik Gautam
6776a429b5 fix: use original gateway text for automatic session titles
Gateway turns reach the agent enriched with channel-bound skill payloads,
sender metadata and other model context, so the first user message the
titler saw could be thousands of characters of skill text ahead of the
actual question, and new sessions were named after the skill instead of
the request.

The gateway now captures the inbound event text before that enrichment and
passes it as a turn-local title_user_message through TurnContext, the turn
runner, AIAgent.run_conversation and the conversation prologue. Only the
titler reads it: the main model, persisted history and relay metadata still
receive the full enriched message. An explicit empty or whitespace override
suppresses titling for that turn, None keeps the existing model-facing
fallback (attachment-only turns, paste previews), and the override cannot
enable model titling for cron or subagent runs. The value is a per-call
argument, so a cached agent never reuses a previous turn's override.

Rebased onto current main: the gateway call keeps main's pinned channel
prompt for internal events, and the integration fixture's context-prompt
stub accepts main's internal= keyword.

Ritvik Gautam made this change with Doryani AI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:57:52 -05:00