After the test-suite prune, the Python slices (~2.3m each) are no longer
CI's critical path — Desktop E2E (5.2m, the longest job) and the Docker
build are, and both run on every python-lane PR even when the diff never
leaves tests/. Neither consumes the test suite: Playwright drives the
built app + hermes serve backend, and the image copies installed code.
New python_prod lane = python minus tests-only diffs. e2e-desktop and
docker gate on it; every pytest/lint lane keeps gating on python.
Fail-open contract preserved: .github/ changes and empty diffs set
python_prod=true, and runner infrastructure (scripts/run_tests.sh,
run_tests_parallel.py) is deliberately NOT tests-only since a bad
runner edit can mask real failures.
Replay over the last 231 main commits: 39 (17%) would skip both jobs,
cutting their critical path from ~8m to ~3m. E2E-verified through the
real script entrypoint (tests-only/prod/mixed/fail-open) + 83 tests/ci
green.
Input events are deliberately skipped for the duration of an IME composition
(they carry uncommitted preedit text), so nothing clears the empty marker
until compositionend — the hint kept painting behind the hiragana the user was
composing. Drop the marker as composition starts; the normalizer restores it
if composition ends with nothing committed.
Taking the hint out of the text flow fixed the displacement half of #75960 on
its own — preedit now starts at the field's left edge either way — but the
overlap needed this too.
Co-authored-by: Ryuichi Natori <to-na@users.noreply.github.com>
The ACP prompt path called set_session_vars(session_key=session_id, ...)
without passing session_id, so the HERMES_SESSION_ID ContextVar was bound
to its explicit "" default. Once the session-context machinery is engaged,
_inject_session_context_env treats an explicitly-bound "" as authoritative
and writes it to the child env — so subprocesses spawned during an ACP turn
got an empty HERMES_SESSION_ID instead of the session's own id.
Pass session_id through so child subprocesses carry the correct id.
Salvage of the ACP half of #53454 by @necoweb3 (the V4A-path half is
salvaged separately in the file-tools PR).
Co-authored-by: necoweb3 <sswdarius@gmail.com>
_normalize_managed_eol isolated line-ending churn from real edits by
diffing twice: all dirty files minus files still dirty under
--ignore-cr-at-eol. But 'git diff --name-only --ignore-cr-at-eol'
computes its file list from blob/stat differences BEFORE the CR filter
is applied, so it still lists CR-only files. On git 2.48.1 the two
name-only sets are therefore identical, _eol_only() is always empty, and
a managed Windows checkout gets pinned to core.autocrlf=false with the
whole CRLF tree left dirty — breaking the next 'git checkout' on update
(the exact failure this function exists to prevent).
Compute the real-edit set with 'git diff --numstat --ignore-cr-at-eol'
instead: numstat honors the CR filter (a CR-only file produces no
record), so eol-only files are correctly identified and cleared while
genuine edits are preserved. Pin core.quotepath=false so non-ASCII paths
parse. Verified at 1200 files: 1199 eol-only normalized, one real edit
preserved, autocrlf pinned only after the tree reads clean.
This was a pre-existing failure on main (test_update_eol_churn's
test_churn_across_more_files_than_fit_in_one_argv failed deterministically
on git 2.48.1), surfaced while landing unrelated file-tools PRs.
The remaining /model picker stall after the Copilot backoff fix: whenever
the 1h provider-models disk cache TTL (or the remote model-catalog manifest
TTL) lapsed mid-session, the next picker open blocked on 8-9 serial
/v1/models round-trips (~2-3s measured) plus the catalog manifest fetch
before rendering anything.
Model catalogs change on release timescales, not hourly — so both caches
now use stale-while-revalidate:
- cached_provider_model_ids(): an expired entry whose credential
fingerprint still matches is served immediately; a deduped daemon thread
re-fetches the live catalog and rewrites the disk cache for the next
open. Entries older than 7 days still block on a live fetch, credential
rotation still busts the entry, and force_refresh still bypasses SWR.
- model_catalog.get_catalog(): an expired disk manifest is served
immediately with an off-thread refresh; only a truly cold cache (no disk
copy) blocks on the network.
Measured picker payload build with deliberately-expired caches:
2.9s -> 0.93s (first open in process) / 0.06s (subsequent opens).
Combined with the Copilot fix (#76386): 7.3s -> ~0.06s for the common case.
The empty-composer prompt was painted with an inline `::before`, which puts a
real box in the contenteditable's text flow. Click an empty composer and the
caret lands past the hint instead of at the field's left edge, and a hint that
wraps — a narrow composer, a long locale string — makes the empty composer two
lines tall. It is a hint, so it now sits out of flow: absolutely positioned,
clipped to one line, unselectable and untouchable by the pointer. Measured in
Chromium against the built stylesheet, the caret lands at the left edge for a
wide composer, a narrow one, and a Japanese hint alike.
Backspace on a fresh `@folder:` chip had a second, related problem. Committing
a completion empties the typed token's text node rather than removing it, and
`Range.insertNode` splits the line around the caret, so the chip ends up
between zero-length text nodes. Those read as content: the atomic chip-delete
declined, Chromium's own backspace bounced between the leftovers, and the chip
took extra presses to remove — leaving a `"\n"` draft with the hint still
hidden behind it. Emptiness, the chip-delete, and the DOM normalizer now all
step over that litter.
The stylesheet owns the rule now that it needs `position: relative` on the
editor, so the utility-class constant both composers imported is gone.
patch_tool resolved V4A header paths against the task workspace for
locking, staleness, and reporting, but handed the original (often
relative) patch text to file_ops.patch_v4a — which re-resolved headers
against the backend env's own cwd. When the two diverge (the git-worktree
cwd bug), a relative header landed in a different directory than
everything the tool locked and reported: a silent wrong-file write.
Rewrite Update/Add/Delete/Move File headers to the resolved absolute
paths before apply, only for host-filesystem backends (container/remote
namespaces keep their own paths). Header patterns mirror patch_parser
(no-space ***Update File: form) and cover Move File: src -> dst.
Salvage of #53176 by @necoweb3, reimplemented onto current main (the
original branch predates the sensitive-path/Move-header extraction and
per-path locking now in patch_tool).
Co-authored-by: necoweb3 <sswdarius@gmail.com>
The boundary scan in parse_v4a_patch used substring matching, so a
content line mentioning "*** End Patch" (docs about the patch format,
nested patch text) truncated the patch, and "*** Begin Patch" in
content reset the start boundary — silently dropping already-parsed
operations while reporting success. Match only whole-line markers at
column 0, preserving the no-space "***Begin Patch" tolerance.
The Desktop client writes the SSH session token under $HOME/.hermes/desktop-ssh
(a literal ~/.hermes/desktop-ssh in apps/desktop/electron/remote-lifecycle.ts,
expanded against the account's $HOME), independent of HERMES_HOME and the active
profile. But _read_ssh_session_token_file validated it against
get_hermes_home()/desktop-ssh, which a non-default sticky profile re-homes to
<root>/profiles/<name>/desktop-ssh (and any custom HERMES_HOME points elsewhere).
relative_to() then rejects every token as "not under the desktop-ssh directory",
so SSH remote mode is broken under any non-default profile.
Anchor to Path.home()/.hermes/desktop-ssh so the validator matches the exact
directory the client writes to, across default, profile, and Docker layouts.
Adds profile / custom-root acceptance tests and a profile-local rejection test.
Fixes#69551.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The desktop lint rule added after the original PR bans mirroring reactive
values into refs via useEffect. Rework the setting to seed from draft-null
state, guard profile switches by stale-config identity, and derive the
save base + rollback value from the shared config record instead of
latestConfigRef/lastSavedRef/seededRef.
Adds a new config option terminal.font_family that lets users customize the
CSS font-family for the desktop app's embedded xterm.js terminal.
Previously the font was hardcoded in use-terminal-session.ts:
'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace
Now the value from config.yaml (terminal.font_family) is threaded through:
useHermesConfig → PersistentTerminal → TerminalTab → useTerminalSession
When font_family is empty or unset (default), the built-in fallback is used,
preserving backward compatibility. Users with Nerd Fonts installed (e.g.
CaskaydiaCoveNerdFont) can now set:
terminal:
font_family: 'CaskaydiaCoveNerdFont', 'JetBrains Mono', monospace
Closes: #terminal-font-config
The no-args /model picker calls list_authenticated_providers(), which walks
every provider through load_pool(). For copilot, _seed_from_singletons()
re-runs the raw-token -> API-token exchange on every pass. When the exchange
is rejected (HTTP 403: token not Copilot-entitled, revoked, org-blocked),
the transient-network retry loop slept ~4.5s (1.5s + 3.0s backoff) before
degrading to the raw token — and nothing cached the failure, so EVERY picker
open, provider discovery pass, delegation spawn, and dashboard credential
listing paid the full 4.5s again.
Measured on a machine with a 403-rejected gh token: /model picker payload
build went from 7.3s to 1.0s cold and 0.06s warm.
Fixes:
- Permanent HTTP rejections (401/403/404) skip the retry backoff entirely —
the loop exists for startup network races, not auth rejections.
- Negative cache keyed on token fingerprint: failed exchanges are not
re-attempted for 30min (auth rejection) / 60s (transient network error).
- Success and evict_cached_exchanged_token() both clear the negative-cache
entry, so the runtime stale-credential recovery path still forces a fresh
exchange.
Two regressions from moving Edit Models into the shared catalog:
The row opened its own separator group, so the composer showed two rules in
the trailing block where it had always shown one. Edit Models now renders
inside the host footer's group — same single separator, same order.
The panel also read the catalog with a non-reactive getQueryData. With no
model in the session store yet (a fresh draft), currentPickerSelection falls
back to the catalog's reported current, and a cache peek never repaints once
that resolves — the menu could sit on a stale or empty selection. Back to a
live useQuery on the same key, which React Query dedupes against the menu's
own subscription rather than double-fetching.
The board listed every model while the composer honoured the user's Edit
Models shortlist, because visibility arrived as a prop each caller opted into.
Curation is one stored preference, so a per-caller opt-in guarantees the two
surfaces eventually disagree about what "my models" means — the exact drift
extracting the menu was meant to end.
ModelCatalogMenu now reads $visibleModels itself and renders the Edit Models
row, so every picker shows the same shortlist and offers the same way to
change it. The composer's footer keeps only Refresh Models. No plugin surface
is involved: the menu is core and the plugin just mounts it.
Also restores the session-scoped catalog fetch the extraction dropped. The
composer's query key must carry its session id — the app invalidates the
session-scoped key on model changes, so a global key would have gone stale
mid-conversation.
The desktop board had no model control at all: a task ran whatever the profile
you assigned it to happened to be configured with, and the only way to point
one task elsewhere was `hermes kanban set-model` or the browser dashboard's
flat provider:model select.
Adds a Model row to New Task and to the task drawer, rendering the composer's
own picker via the SDK — same search, same provider groups, same submenu — so
the board and the chat bar cannot drift. Unset reads "Profile default" and
changes nothing; a pin reads "provider: model · High" with an inline clear.
Presets are read-only here: picking a model seeds the depth from what you last
used for it, but a per-task choice never rewrites what the composer opens at.
Fast mode is omitted rather than shown-and-ignored — it's a live-session
request parameter with no worker-spawn equivalent.
The New Task dialog opts out of DialogContent's clip: the dialog publishes
itself as the portal container for popovers opened inside it, so its
overflow-y-auto cropped the menu at the dialog's edge. The general fix is in
flight as #75600; this override is scoped to one dialog to avoid conflicting
with it and disappears when that lands.
The model menu — search, provider grouping, -fast family collapse, keyboard
selection, the per-row thinking/effort submenu — was welded to the chat
composer's session writes, so any other surface wanting a model picker had to
fork it and drift.
Splits rendering from meaning. ModelCatalogMenu owns the catalog and the
navigation; a ModelMenuController decides what a selection DOES. The composer
is now one controller over it, keeping its session scoping, sticky manual
pick, preset restore, MoA presets, and rollback-on-failed-write intact.
ModelEditSubmenu becomes pure: it reports edits instead of performing them.
It previously called setCurrentReasoningEffort and config.set inline, so any
non-composer host would have silently retargeted the user's live chat when
they picked an effort. Its default effort is passed in rather than read from a
store, which is what lets it render outside a session at all.
Exported through the SDK so plugins consume the real component instead of a
copy. The composer's existing behaviour suite passes unchanged against it.
Carries the new column through create, PATCH, and bulk. Clearing is an
explicit clear_reasoning_effort flag rather than a null, because a null in a
PATCH body means "field not sent", not "set to NULL" — the same shape the
model override already uses, and the reason "none" can stay a real value.
Tests cover normalization, the depth-survives-a-model-clear invariant, both
spawn-argv branches, and the REST round-trip. One asserts the worker CLI
actually accepts the --reasoning flag the dispatcher emits: a spawn arg no
parser accepts would fail every dispatch while every unit test stayed green.
A task could already pin a model and provider, but not how hard the worker
thinks: reasoning effort came from the assigned profile's config and nothing
per-task could reach it. Pairing a small model with high effort, or a big one
with thinking off, meant editing the worker profile itself.
Adds a tasks.reasoning_effort column (migrated, NULL = inherit the profile)
with set_reasoning_effort(), a create_task kwarg, and a --reasoning spawn flag.
Kept deliberately independent of model_override: a task may run the profile's
own model at a different depth, and clearing a model override no longer resets
the depth the operator chose. "none" is a value (thinking off), not a clear.
--reasoning is new on the CLI too — the level was only reachable through the
/reasoning slash command, so the dispatcher had no flag to pass. It overrides
agent.reasoning_effort for one run and is never persisted.
The micro-action strip owned this skin inline: a full-radius hairline pill
on the composer's own fill behind a blur, sized to `--composer-control-size`.
It's the right look for anything that floats over the composer, so lift it
into `composer-dock` next to the other shared composer surfaces and have the
strip compose it with its own width cap and disabled state.
Follow-ups from sweeper review of #43763:
- tests/docker/test_tini_compat_shim.py asserts the dispatcher
ENTRYPOINT (with /init delegation check) instead of a bare /init
- tests/docker/test_smoke.py gains a docker run --init regression
for the non-PID-1 fallback (#38349)
- website/docs/user-guide/docker.md and the s6 supervision skill
document the dispatcher and its wrapped-runtime fallback
Replace the bare /init ENTRYPOINT with entrypoint-dispatch.sh: exec
/init + main-wrapper when the image owns PID 1, fall back to a direct
stage2 bootstrap (with the s6 helper PATH restored) on wrapped runtimes
where s6-overlay-suexec would abort with 'can only run as pid 1'
(Fly Machines, docker run --init, podman/FreeBSD setups).
Cherry-picked from PR #43763 by @konsisumer, conflicts with current
main resolved (tests/test_dockerfile_tini_compat_shim.py was moved to
tests/docker/, container_boot argv tests were reshaped upstream).
Fixes#38349
A local pin/unpin fires reconcile synchronously via the
$pinnedSessionIds listener — before any PATCH exists — so
pullRemotePins() read the still-stale server row and immediately
undid the user's action: !row.pinned && heldLocally reverted a fresh
pin, and row.pinned && !heldLocally re-pinned a fresh unpin, after
which the push pass saw nothing to write and no PATCH ever fired.
Fence local intent ahead of the pull:
- Run the push pass first, so pending/unconfirmed record the local
intent before the page is read, then pull.
- Skip the pull for ids still in `pending` (row not yet resolved) —
local intent awaiting its PATCH is newer than any loaded page.
- Update mirrored bookkeeping before mutating the pin store inside
the pull, so the re-entrant reconcile doesn't echo adopted state
back as redundant PATCHes.
Regression tests cover both directions (fresh pin over a stale
pinned=false row, fresh unpin over a stale pinned=true row) plus the
deferred-pin case where the row loads stale after the toggle. All
three fail against the previous reconcile order.
Fixes#74570
Extends the shared _GATEWAY_LIFECYCLE_PATTERN (used by BOTH the cron
creation-time guard in cron/lifecycle_guard.py and the terminal
execution-time hard-block in tools/terminal_tool.py) so Branch B covers
launchctl submit and bootstrap alongside kickstart/unload/load/stop/
restart, and normalizes POSIX shell line continuations before matching
so the exact multi-line reported shape in #62891 cannot slip past.
Also extends the execution-aware, label-independent detector
(contains_launchctl_submit_command, cherry-picked from #63272) to cover
launchctl bootstrap, since a neutral label like ai.hermes.svc-reload-tmp
defeats any label-anchored regex — the second production reproduction.
Regression tests cover both sites, including
`launchctl submit -l com.foo -- /path/gateway` and the bootstrap
variant, plus outside-gateway pass-through.
Branch B regex extension and continuation normalization drawn from
PR #62896; bootstrap coverage and test shapes drawn from PR #51003.
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com>
- Resolve guard cwd against get_session_cwd(session_key); fall back to env.cwd
when no session record exists yet, matching current main's per-session cwd
architecture.
- Make referenced-script reads backend-aware: local read first; if missing,
fall back to env.execute('cat ...') for SSH/Modal/Daytona backends.
- Reuse the recursive scanner in check_gateway_lifecycle so nested cron
wrapper scripts are caught, and resolve relative refs inside a script
against that script's directory.
- Add regression tests for remote-backend reads, two-session cwd, and nested
cron wrappers.
Verification: 80 passed tests/hermes_cli/test_gateway_restart_loop.py;
694 passed tests/cron; ruff + git diff --check clean.
A successful finalize edit can carry only the last streamed preview
snapshot: deltas generated between the last preview edit and stream
completion never reach any Bot API call, yet final_response_sent /
final_content_delivered were set from the call's success and suppressed
the gateway's normal final send — losing the tail permanently.
The stream consumer now records the exact cleaned payload of every
turn-final delivery (delivered_final_matches tri-state), and gateway/run.py
reconciles that record against the completed final_response before
trusting either suppression flag. On a demonstrable mismatch it edits the
streamed message up to the complete response, falling back to the normal
final send if the edit fails. Multi-message split deliveries and legacy
paths without a record keep the existing flag-trusting behavior, so
overflow splits and the failed-finalize handling (#51828/#33793) are
untouched.
Fixes#71643
CI's plugin-test slice runs without the discord optional extra; the raw
import failed with ModuleNotFoundError while every other test in the
file uses injected mock modules.
Under gateway.multiplex_profiles, Discord and Telegram authorization gates
(allowed/ignored channels, allowed users/roles, allow-all flags) were read
from process-global os.environ, populated first-writer-wins by the YAML->env
bridge in each adapter's _apply_yaml_config. The first profile to initialize
pinned its allow/deny lists — and its ALLOW_ALL flags — for every other
profile in the process (issue #72348, incl. the Telegram mirror reported in
the thread).
Fix (per-adapter-instance gate reads, whole class):
- gateway/authz_mixin.py: new _platform_gate_env — scope-authoritative gate
read: under an installed profile secret scope with multiplex active, a
missing key returns the default instead of falling through to os.environ
(which may hold another profile's value). Single-profile behavior is
byte-identical to os.getenv.
- Discord adapter:
- connect() snapshots all gate env vars (_GATE_ENV_KEYS) inside the owning
profile's runtime scope into a per-adapter dict; new accessors
(_get_allowed_channels/_get_ignored_channels/_get_allowed_users/
_get_allowed_roles/_get_no_thread_channels/_discord_allow_all_users/
_gateway_allow_all_users/_get_allow_bots) resolve snapshot -> config.extra
-> scope-aware env, replacing every raw os.getenv gate read: on_message
channel gates, _is_allowed_user allow-all flags, slash authorization,
fail-closed diagnostics, missed-message backfill, bot-message gating,
and _component_check_auth (component buttons).
- _apply_yaml_config always seeds gate values into PlatformConfig.extra
(incl. new allowed_roles / allow_all_users keys) and SKIPS the
process-global env writes when loading a profile-scoped config under
multiplex; the legacy first-writer env bridge is preserved verbatim for
single-profile deployments.
- _resolve_allowed_usernames no longer unconditionally rewrites
os.environ[DISCORD_ALLOWED_USERS] — under multiplex the resolved IDs stay
adapter-local (snapshot refresh); single-profile keeps the env rewrite.
- Telegram adapter (mirror of the same class): intake prefilter and
callback-auth fallbacks, _telegram_auth_env_configured, and the
allowed/ignored chats-topics-threads getters now read via the scoped gate
reader; _apply_yaml_config skips authorization env writes for
profile-scoped loads and seeds free_response_chats/ignored_threads extras.
Regression tests (tests/plugins/platforms/test_discord_gate_isolation.py):
two adapter instances with different allow-lists enforce their OWN lists
order-independently across message, slash, and component gates; negative
allow-all case proves profile A's open-access flag cannot authorize profile
B; username-resolution env-clobber; YAML-bridge seeding/skip matrix; and the
Telegram scoped-reader matrix. Sabotage-verified: reverting either the
Discord snapshot accessors or the Telegram scoped reader fails 12/2 tests
respectively.
Credit: builds on the per-adapter accessor direction of PR #72427
(@JonthanaHanh) and the scope-aware-reader approach validated live on v0.19.0
by @yournetworkplug-ctrl for the Telegram mirror; scope corrections from
jackjin1997's and cal88's analysis in the issue thread (allow-all flags,
unguarded username-resolution env write, per-site channel reads).
Fixes#72348
Builds on #72787's current_root guard (cherry-picked with authorship
preserved). Two further hardenings for #72776:
- require the settled cwd and the workspace to share the same common .git
dir (the shape 'git worktree add' produces), so a git workspace visiting
an UNRELATED repo is a browsing visit, not a re-home (repro'd by
Johnny-xuan in the issue thread);
- never reconcile over an explicitly chosen workspace (explicit_cwd),
while a settle-adopted cwd stays followable via a cwd_from_settle
marker cleared by _set_session_cwd / project switch.
Fixes#72776
`_reconcile_session_cwd_from_terminal()` treats a settled terminal cwd in a
different git working tree than the session's workspace as a relocation. But
when the session's workspace is not itself in a git repo, `_git_repo_root_for_cwd(current)`
is None, so the `landed == _git_repo_root_for_cwd(current)` guard never matches
and the FIRST git directory a tool call steps into hijacks the session: its
cwd/git_repo_root flip to that repo, later tools run from the wrong place, and
the repo's AGENTS.md gets injected into an unrelated conversation.
Only reconcile when both the current and settled cwds resolve to valid, differing
git roots. A non-git workspace visiting a git repo to read a file or run a command
is a browsing visit, not a re-home — matching the docstring's own intent that
`cd`-ing away must not re-home the chat. The intended "follow into a worktree"
behavior is unaffected: that path starts from a git checkout, so current_root is
valid there.
Adds a regression test covering a non-git workspace that touches a git repo.
Follow-up to the a11d0bdb01 dedupe of test_doctor.py. Two gaps in the
surviving copies:
- Nine tests stubbed get_nous_auth_status, but run_doctor calls
get_nous_auth_status_local (hermes_cli/doctor.py:1373-1380) — the
stubs weren't stubbing the called function. Point them at the local
variant, keeping the gemini OAuth stub where the newer copies had it.
- The catalog-alias parametrize lost its nvidia and moa cases in the
dedupe; restore them alongside ai-gateway.
54/54 pass (test_doctor.py + the shadow guard).