Skip to content

Sync current Nous Hermes main into Ace patches - #41

Merged
vashkartik merged 6707 commits into
ace/patchesfrom
automation/hermes-nightly-sync
Aug 12, 2026
Merged

Sync current Nous Hermes main into Ace patches#41
vashkartik merged 6707 commits into
ace/patchesfrom
automation/hermes-nightly-sync

Conversation

@vashkartik

Copy link
Copy Markdown
Owner

Automated fail-closed upstream sync. Locally verified head: 2d12e5d

teknium1 and others added 30 commits August 9, 2026 14:08
…f discarding

When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION
(200) while the session DB is broken, the gateway previously popped the
oldest message and discarded it permanently — silent user data loss during
live operation (NousResearch#78182). The on-disk pending spool only ran at shutdown via
flush_pending_to_file.

Extend that existing spool machinery for runtime drops:

- gateway/shutdown_flush.py: add spool_dropped_transcript_message() and
  drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same
  atomic-JSON pending_messages/ spool format). recover_pending_to_db()
  now also replays transcript_cap_drop payloads left over across restarts.
- gateway/session.py: on cap eviction, spool the dropped message and log a
  WARNING that includes the spool path; if spooling fails, degrade to the
  previous drop-and-warn behavior. On the next fully successful transcript
  flush for that session, drain and replay spooled messages in drop order;
  replay failures keep the spool files for the next attempt.
- tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip,
  per-session drain isolation, spool-failure degradation, replay-failure
  retention, and spool primitive ordering/reason filtering.

No new config; extends existing flush_pending_to_file infrastructure per
AGENTS.md guidance.

Refs NousResearch#82616, NousResearch#78182
…pace mounts

Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):

1. A NEW chat's container inherited the PREVIOUS session's workspace,
   bind-mounted rw at /workspace, because the mount source was the
   process-global TERMINAL_CWD env var (written by the workspace picker,
   outliving its session) and all sessions shared one 'default' container.

2. Every command failed with exit 126 because the desktop gateway recorded
   the HOST launch directory as the session cwd, and each command was
   prefixed with 'cd /Users/<user>/...' inside the container.

Fixes (class-wide, single owners):

- container_persistent: false + docker now keys containers PER SESSION:
  fresh container per chat, removed at session close/idle. delegate_task
  children share the parent's container via an explicit alias registry.
  container_persistent: true keeps the documented ONE-long-lived-container
  contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
  policy across all four env-creation sites; under isolation it refuses
  process-global cwd sources and mounts only the session's own attached
  workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
  sites already had (NousResearch#50636/NousResearch#54447 sibling site): a recorded host cwd is
  discarded on container backends instead of cd-ing every command into a
  nonexistent path.

E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.
…demod optional skill

Vendors the ast-grep skill from oh-my-openagent's shared-skills bundle
(upstream code-yeongyu/ast-grep-skill @ 3148c69, MIT) into
optional-skills/software-development/ast-grep with Hermes conventions:

- SKILL.md rewritten with Hermes frontmatter (platforms, tags, category)
  and Hermes tool routing (search_files instead of raw rg, terminal for
  sg invocations, patch-vs-ast-grep division of labor)
- scripts/ast_grep_helper.py: fixed argparse so trailing paths after an
  optional flag parse (parse_known_args + fold extras into paths);
  upstream errored 'unrecognized arguments: .' on the documented
  'search PATTERN --lang js .' form
- 7 reference docs, install.sh/install.ps1 (pinned-release GitHub
  fallback), smoke tests carried over verbatim

E2E validated: install (github method, ast-grep 0.45.0), doctor,
search, validate (regex rejection), replace dry-run + apply two-pass,
scan with YAML rule, tests/smoke.sh 15/15 pass.
…mand

The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.

Two paths had this fault:

- tool.start: the payload had no args until tool.complete, so the
  expanded row was truncated while the tool ran. Now tool.start ships
  the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
  discarded them. Hydration from this projection (watch windows,
  compress, branch, seeded create) kept only the preview, so the
  truncation was permanent. Now tool rows carry the args. This
  projection is the display view of the transcript — each renderer
  decides what to paint, and the preview stays for collapsed titles.

The DB rows do not change: the args already persist in tool_calls.
test_authoring_standards.py::test_description_hardline red on main since
461c493 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.
…sessions too

Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.

_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).

Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).

Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.

Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.
…reates

The sweeper flagged two gaps in the routing-columns fix:

1. /branch create_session() omitted user_id and session_key — the
   fallback lookup path (find_latest_gateway_session_for_peer) requires
   user_id to match the complete peer tuple when session_key lookup fails,
   and /resume IDOR guards reject sessions without matching user_id.

2. Compression-rotation create_session() omitted agent._user_id — same
   problem: rotated child cannot satisfy persisted /resume ownership proof
   before the later gateway backfill.

Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.

Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.
…ons too

Complete the /branch routing-identity fix (salvaged from PR NousResearch#62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (NousResearch#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().

The obsolete compression-rotation half of NousResearch#62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.
…68539)

find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.

Rebuilt against the NousResearch#82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.

Cherry-picked from NousResearch#68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562)
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-NousResearch#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from NousResearch#78618 and adapted to the NousResearch#82633 finder.
(cherry picked from commit 31c71f7)
…mes (NousResearch#74411)

Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.

Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.

Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.
…hrough in locateHermes

Replaces the canonicalization test (which pinned the behavior NousResearch#74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ght theme

streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.

traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.

fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.

- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
  on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
  marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
  replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
  imports. -m filters after collection, and collection imports every
  module. without this helper, one unrelated ImportError on the
  foreign host fails a job whose own tests passed. the helper exits
  non-zero when a marker matches no file, and writes bytes with
  explicit lf so windows crlf translation cannot corrupt the bash
  file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
  macos_only/windows_only tests were skipped on this host, and this
  ci lane runs them. a green local run on linux no longer reads as
  coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.
`shell: bash` runs the step with -e injected, and `set -uo pipefail` does
not clear it. A non-zero pytest exit killed the script before `status=$?`,
so the -eq 5 branch and its ::error message never ran. The job still failed
red, but the diagnostic that names the cause never printed.
Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:

- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
  picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
  in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
  marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
  $BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
  fallback could not change the result. The assertion now reads the host, so
  the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
  CoreAudio init raises a TCC prompt, which no Linux runner reproduces.

tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.

The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.
The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.

The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.
…esearch#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (NousResearch#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (NousResearch#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <[email protected]>
- warn (not debug) on final text-turn flush failure: a failure here
  reopens the exact NousResearch#81641 data-loss window with _persist_session as
  the only remaining retry, unlike the verify siblings which retry
  in-loop; include session id for triage
- trim the flush-site comment to sibling proportion, pointing to the
  test module for the full incident narrative
- test: assert _persist_session presence before indexing, so a wiring
  change fails with a clean assertion instead of ValueError from max()
Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines
each. Fix the same bug class in the compression summary path at
chat_completion_helpers.py: remove _thinking_prefill from the explicit
pop tuple and move the generic underscore-key sweep to after
_drop_thinking_only_and_merge_users, so the drop pass can recognize
prefill stubs there too.
_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.

Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.

Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.

Reported-by: JoaoMarcos44 <[email protected]>
kshitijk4poor and others added 26 commits August 12, 2026 13:20
The TUI resolves the CLI via process.env.HERMES_BIN (externalCli.ts) and
falls back to a bare 'hermes', which is not on PATH for nix run / nix
profile installs that only expose the wrapped binaries. Set a
--set-default so the wrapper advertises its own hermes while an explicit
operator override (documented in kanban_db.py) still wins.
…ows (NousResearch#84364)

* fix: warn agents off driving interactive console TUIs via pty on Windows

Driving 'gh auth login' (and other survey-style console TUIs) through a
pty background process on Windows silently hangs: these programs read
Win32 console key events via ReadConsoleInput, not the stdin byte
stream, so Enter keypresses submitted over process stdin never register.
The agent-visible symptom is a prompt frozen at 'Press Enter to open
browser...' while the user sees nothing, and a turn interrupt then kills
the process, invalidating any device code the user already entered on
github.com.

Two guidance fixes, both proven in a live session on Windows 10:

- agent/prompt_builder.py: extend _WINDOWS_BASH_SHELL_HINT to steer
  agents toward non-interactive paths (flags, --with-token, config
  files, curl-polled OAuth device flow) instead of answering console
  prompts programmatically.
- skills/github/github-auth: document the pitfall and add the manual
  OAuth device-flow procedure (curl against gh's public client_id,
  poll for the token, finish with 'gh auth login --with-token'), which
  succeeded first try after two interactive attempts hung.

* fix: send CRLF for Enter on Windows PTY submit; correct root cause in guidance

Review feedback (helix4u) was right on both counts:

1. Root cause correction. gh's 'Press Enter to open browser' prompt is
   waitForEnter -> bufio.Scanner reading stdin, not a survey/console-API
   prompt. The real bug is ours: submit_stdin appended a bare \n, and
   through pywinpty/ConPTY a lone \n is not delivered as a line
   terminator, so the child's blocking line read never returns. Verified
   empirically against pywinpty 2.0.15 with a readline() child:
   \n -> hang, \r -> line delivered, \r\n -> line delivered.

   Fix: submit_stdin now appends \r\n for Windows PTY sessions (POSIX
   PTYs and Popen pipes keep \n). Windows-only regression tests cover
   the PTY and pipe branches.

2. Prompt hint rewritten: instead of claiming Windows console TUIs
   cannot be driven, it now says to use process(submit) rather than raw
   writes with bare \n, and to prefer non-interactive paths when a CLI
   offers one.

3. Skill device flow rewritten as an executable script: parses the
   device-code response, polls per the returned interval, handles
   authorization_pending / slow_down (+5s per GitHub docs) /
   expired_token / access_denied / unexpected responses, pipes the token
   straight into gh without echoing it, and drops the undocumented
   workflow scope (repo,read:org,gist is the documented minimum for
   gh auth login --with-token). The pitfall note is narrowed to the
   reproduced condition.
…ousResearch#84383)

* fix: make verify_on_stop opt-in everywhere (default False, not auto)

The verify-on-stop nudge was already judged more noise than signal: the
v31 migration flips existing installs off, the v32 migration catches the
baked-in literal-true population, and the docs tell users to 'treat off
as the effective default and opt in explicitly'. But DEFAULT_CONFIG still
shipped the "auto" sentinel, so exactly one population kept getting the
nudges: fresh installs (and any config missing the key), where "auto"
resolves ON for CLI/TUI/desktop surfaces. Live symptom: repeated
'[System: You edited code ... run verification]' interruptions the user
never asked for and had to hunt down in source to disable.

- DEFAULT_CONFIG: agent.verify_on_stop "auto" -> False (opt-in).
- verify_on_stop_enabled(): missing/unrecognized value now falls back
  OFF instead of surface-aware; explicit "auto" still selects the
  legacy surface-aware behavior, explicit bools unchanged, and the
  HERMES_VERIFY_ON_STOP env override is untouched.
- No migration needed: v31/v32 already normalized existing installs,
  and this only changes the merged default for configs without the key.
- Docs updated; default-path E2E test now asserts OFF, plus a new
  missing-value regression test. Also added the standard win32 skip
  marker to the symlink-based temp-dir test (pre-existing Windows
  failure, same class as tests/cron/test_cron_script.py).

* test: update config goldens — verify_on_stop=False is now stripped as default

With the DEFAULT_CONFIG flip to False, the migration-write invariant
(_persist_migration / save_config strip_defaults) no longer materialises
verify_on_stop: false to disk unless the user explicitly set the key:

- V20 floor fixture (agent: {} on disk): v31's write is stripped —
  agent stays {} and load_config() supplies False at read time.
- V12 floor fixture (explicit verify_on_stop: true on disk): the key is
  a user-set path, so the v32 flip stays materialised as false.
- Partial-write and _persist_migration regressions now assert the key is
  absent from disk and (for the merge case) that the merged view still
  resolves False.

Behavior verified with a one-shot migrate_config run against both
fixture shapes.
…drift (NousResearch#84378)

* fix: Windows path handling in search_files rg calls and patch escape drift

Two related Windows failures from a live session (Windows 10, git-bash
terminal backend, winget-installed native ripgrep):

1. search_files was unusable on drive-letter paths. _escape_shell_arg
   rewrites C:\... to the MSYS form /c/... so bash builtins resolve it,
   but rg is a native Windows binary and Hermes disables MSYS argument
   conversion for its bash subprocesses (MSYS_NO_PATHCONV=1 /
   MSYS2_ARG_CONV_EXCL=*, see _apply_windows_msys_bash_env_defaults) —
   so nothing ever translated /c/... back and every search failed with
   'The system cannot find the path specified. (os error 3)'.

   Fix: new _escape_native_tool_arg emits the forward-slash NATIVE form
   (C:/Users/...), which native binaries accept, bash passes through
   untouched, and MSYS builds also handle. Applied to the six rg call
   sites (content search, --files search x2, zero-match probe x3); the
   grep fallback keeps the MSYS form since MSYS grep wants it.

2. The patch tool silently doubled backslash runs when tool-call args
   arrived JSON-escaped one extra time (file had \ where old_string
   had \\). Similarity strategies (context_aware) matched the region
   anyway and wrote new_string verbatim, corrupting every backslash run
   (reproduced: 6 backslashes on the line became 12). _detect_escape_drift
   now also blocks when every backslash run in old_string is exactly twice
   its counterpart in the matched region and new_string repeats the
   doubling — with guardrails so exact matches, intentional backslash
   edits, model-corrected new_strings, and single weak-signal runs all
   still apply. Blocking returns the standard escape-drift guidance so
   the model re-reads and retries with correct counts.

Tests: TestEscapeNativeToolArg (5 cases, including an end-to-end
_search_with_rg command capture) and TestBackslashDoublingDrift (6
cases). The 8 pre-existing failures in tests/tools/test_file_operations.py
on a Windows host (umask/symlink POSIX assumptions) are identical on
unmodified main and unrelated.

* fix: shell linters get native Windows paths too (node C:\c\... double-prefix)

Same class as the rg fix: LINTERS commands (python -m py_compile,
node --check, npx tsc, go vet, rustfmt) invoke native Windows binaries,
but _check_lint interpolated the MSYS /c/... form. node resolves that
as C:\c\Users\... (double-prefixed), so on Windows hosts every .js
write reported a phantom ENOENT lint failure that could mask real
syntax errors (issue NousResearch#84303). Route the {file} arg through
_escape_native_tool_arg like the rg call sites.

Regression test asserts node --check receives 'C:/...' and never
'/c/...'.
_strip_quotes documented that it stripped heredoc bodies but only handled
single/double/backtick quotes. As a result _foreground_background_guidance
scanned heredoc body text for a backgrounding '&' and wrongly rejected valid
foreground commands whose heredoc body contained a spaced ampersand — e.g.
AppleScript string concat (osascript <<'EOF' ... "a" & b ... EOF), Python
bitwise-and, or literal UI text like 'FaceTime & Privacy'.

Add a _strip_heredocs pass (runs before quote-stripping, since a heredoc
delimiter may itself be quoted) covering <<EOF, <<-EOF, <<'EOF', <<"EOF".
The same-line tail after the opener (redirects/args) is preserved and the
opener token is blanked so a real backgrounding '&' after the heredoc is
still detected.

Adds tests/tools/test_terminal_heredoc_background_guard.py.
The previous commit's regex-based stripper removed EVERY heredoc body,
which review flagged as bypassable: a fake '<<EOF' marker inside a
comment or quoted string enters the unterminated path and swallows a
later REAL background operator, and unquoted ('cat <<EOF' — expansion
runs) or shell-consumed ('bash <<'EOF'' — body IS shell) bodies are
executable content that must stay visible to the guard.

Replace it with tools/shell_heredoc.strip_inert_heredoc_bodies(), a
conservative shell-state scanner: a body is masked ONLY when every
delimiter on the opener is quoted (no expansion), every heredoc is
terminated by an exact delimiter line, the opener composes a single
command (no list/pipeline operators, no nested $()/backtick/process-
substitution scope), and the consumer is an allowlisted non-shell
interpreter (python/osascript/cat). Anything ambiguous is returned
unchanged — a false positive on exotic syntax is acceptable; hiding a
real background operator is not. Masked bodies become newlines so line
structure is preserved for MULTILINE regexes.

The helper is a standalone stdlib-only module (precedent:
tools/ansi_strip.py) because the same heredoc-as-data false-positive
class exists in the blocked-command regex checks (NousResearch#83104) and the
gateway lifecycle guard (NousResearch#81721/NousResearch#79835, cron/lifecycle_guard.py) —
which must not import the terminal-tool module graph.

Adapted from Wolfram Ravenwolf's security-hardened rework of NousResearch#63788
(69c7663); test scenarios for the
bypass cases derive from his suite.

Co-authored-by: Wolfram Ravenwolf <[email protected]>
Efficiency review (measured with timeit probes) found two unbounded
costs on adversarial inputs:

- The masked-range rebuild copied the whole string once per range
  (O(n*k)): 50k tiny heredocs took 1.7s. Replaced with a single-pass
  segment join over the (sorted, non-overlapping) ranges: 152ms, and
  newlines are now counted on the original command instead of
  re-slicing.
- After the last '<<' occurrence no opener can start, but the scanner
  still walked the remaining text per-char: one heredoc followed by a
  1MB tail cost ~150ms. An rfind bound breaks out of the unit loop
  once the scan passes it: 0.3ms.

Typical commands are unaffected (the '<<' fast path already returns
first). 30/30 guard tests pass; mutation check re-run on the final
stack (no-op mutation -> 11 tests fail, restore -> green).
MiniMax-M3 ships server-side automatic prefix caching on the
Anthropic-compatible endpoint (content-keyed, no marker needed —
see platform.minimax.io/docs/api-reference/text-prompt-caching).
cache_control markers are NOT on its explicit-cache support list
(which covers only M2.7/M2.5/M2.1/M2).

Emitting markers on M3:
  - wasted serialization overhead
  - risked perturbing the server-side prefix hash
  - gave users a false sense of explicit-cache savings (the
    cache_read_input_tokens field carries a +128 constant floor
    and cache_creation_input_tokens is always 0 for M3)

Also add an opt-in debug=True parameter to normalize_usage() that
emits a debug-level log line carrying the observable cache fields.
This is the only reliable cache signal for M3 — off by default,
debug-level, scoped to the anthropic_messages wire, so production
callers see no impact.

Pin both changes with 8 new tests:
  - 4 M3 tests covering provider, host, and custom-provider paths
  - 1 regression guard ensuring M2.x caching is unaffected
  - 3 observability tests (off-by-default, on-with-M3, on-with-Claude)

Verified end-to-end against api.minimaxi.com/anthropic/v1/messages
with MiniMax-M3[1m]: identical system prompt hit-rate with and
without markers; cache_read field is unreliable (128 floor),
input_tokens drop (8467 -> 1) is the real hit signal.
…ervability

Follow-up fixes on top of the salvaged NousResearch#83678 commit:

1. Hoist the MiniMax-M3 marker exclusion ABOVE the native-Anthropic
   early return. provider="anthropic" pointed at a MiniMax /anthropic
   proxy is a supported override (_anthropic_base_url_override_ok), and
   the is_native_anthropic branch matched on provider alone — returning
   (True, True) before the M3 exclusion was reached. Two regression
   tests pin the proxy route (M3 off, M2.7 still on).

2. Reuse the existing _model_name_suggests_minimax_m3() helper from
   agent/model_metadata.py instead of a second inline substring copy.

3. Drop the debug kwarg on normalize_usage() — it had zero production
   callers and duplicated standard logging level gating. The
   cache-observability line is now a plain logger.debug scoped to
   MiniMax providers on the Anthropic wire only, so the "+128 floor"
   note can no longer appear for native Anthropic where it is false.
   Tests updated accordingly (MiniMax logs, native Anthropic does not).
Salvaged PR NousResearch#83678's commit is authored under a generic local agent
identity with no linked GitHub account; map it to the PR opener for
release attribution (same pattern as [email protected]).
…lete, screenshots, OS detection (NousResearch#84419)

Sweep of open Windows issues affecting day-to-day agent operation
(explicitly excluding install/setup and locale classes):

- hermes_cli/_subprocess_compat.py: new split_command_line() — Windows-
  safe command-line tokenizer (posix=False + quote stripping) so
  backslash paths survive. POSIX behavior unchanged (plain shlex.split).

- hermes_cli/console_engine.py (NousResearch#83934): console commands like
  'sessions export C:\Users\me\out.jsonl' no longer silently mangle the
  path into a relative filename in the cwd.

- agent/shell_hooks.py (NousResearch#78293): hook commands with backslash paths now
  spawn, resolve their script path, and pass hooks doctor instead of
  reporting 'not executable'. All three shlex sites routed through the
  shared splitter.

- agent/prompt_builder.py (NousResearch#51755): system prompt now reports
  Windows (11) on Windows 11 — platform.release() returns 10 for both;
  distinguish via sys.getwindowsversion().build >= 22000.

- hermes_cli/commands.py (NousResearch#42016): @ autocomplete no longer crashes the
  prompt_toolkit event loop when rg emits a path on a different mount
  (device paths \.\nul, other drive letters) — relpath ValueError is
  skipped per-entry.

- tools/browser_use_cli.py (NousResearch#83884): screenshot-path detection now
  matches Windows drive-letter paths (C:\... and C:/...) in addition to
  POSIX; Browser Use screenshots attach on Windows.

- tools/skills_hub.py + tools/skills_guard.py (NousResearch#62310): the two 'MUST
  stay symmetric' skill content hashes actually agree on Windows now.
  Bundle keys are normalized to POSIX separators before hashing, and the
  disk digest sorts by rel-posix STRING (case-sensitive) instead of Path
  objects (case-insensitive on Windows). Fixes permanent false-positive
  update_available for every installed skill.

Tests: tests/tools/test_windows_agent_loop_papercuts.py — 16 cases
covering each fix, including a disk-vs-bundle hash symmetry check built
with native Windows separators and a mixed-case filename.
…eservation (NousResearch#84426)

Two follow-ups from live Windows sessions:

1. agent/prompt_builder.py: extend the Windows shell hint with the
   native-binary path rule. Hermes disables MSYS path conversion for its
   bash, so agents passing /c/Users/... or /tmp/... to NATIVE programs
   (git -C, node, python, rg) hit 'cannot change to' / 'not found' while
   the same path works in bash builtins — observed repeatedly in a live
   session (git -C failures, git apply /tmp/x.patch failures). The hint
   now says: forward-slash native form (C:/Users/x) for native tools,
   $LOCALAPPDATA/Temp over /tmp for scratch files native tools read.
   (/tmp is pure model habit from Linux training data — nothing
   instructs it — so the hint is the right layer.)

2. tests: pin LF/CRLF preservation through write_file and patch_replace.
   A live session saw a repo-LF file come back full-CRLF after an edit
   (4699-line diff churn); not reproducible through current tool APIs,
   so pin the correct behavior — LF files stay LF, CRLF files stay CRLF,
   no mixed endings — to catch any regression on the Windows write path.
…d paths (NousResearch#84428)

Fixes NousResearch#69472. On a Windows host every destructive native command passed
approval silently — DANGEROUS_PATTERNS were POSIX-shaped, and the
normalizer strips backslashes as shell escapes so no Windows path could
ever match a path rule. Probed live before the fix: 15 of 15 destructive
Windows commands (Remove-Item -Recurse -Force, del /s /q, iwr | iex,
taskkill /F, Format-Volume, diskpart, icacls /grant Everyone, vssadmin
delete shadows, bcdedit /set, reg delete, cipher /w, ...) sailed through
undetected.

Two changes:

1. Windows destructive tier in DANGEROUS_PATTERNS: PowerShell deletes
   (bare Remove-Item -Recurse/-Force), cmd builtins with /s|/q switches,
   iwr|iex remote execution (pipe and subexpression forms), taskkill /F /
   Stop-Process -Force, volume/disk destruction (Format-Volume,
   Clear-Disk, diskpart, format.com, cipher /w), icacls Everyone-grant /
   /reset, backup destruction (vssadmin delete shadows, wbadmin delete,
   bcdedit /set), reg delete / Remove-ItemProperty -Force, and service
   stop/delete (Stop-Service -Force, sc stop|delete). Each pattern
   requires the destructive flag so graceful/read-only usage (taskkill
   /IM without /F, reg query, icacls inspect, sc query, plain del file)
   does not prompt. Patterns live in the main list, not a win32-gated
   tier: a Linux-hosted Hermes can drive a Windows box over SSH.

2. Windows-path detection variant in _command_detection_variants: when
   the raw command contains a drive-letter/UNC backslash path, also
   yield a variant with backslashes flattened to forward slashes BEFORE
   normalization strips them, plus Windows spellings of the credential
   path rules (Users/<u>/.ssh, AppData/{Local,Roaming}/hermes .env).
   Gated on a real path shape so POSIX escape semantics are untouched.

Tests: tests/tools/test_approval_windows.py — 48 cases (27 destructive
flagged, 13 benign not flagged, 5 credential paths in both separator
spellings, 4 POSIX-escape non-regressions). The 8 pre-existing failures
under '-k approval' on this Windows host are identical on unmodified
main (ordering artifacts + known symlink cases) and unrelated.
…form skills (NousResearch#84429)

Two Windows agent-loop friction fixes:

1. tools/mcp_tool.py (NousResearch#56536): shutil.which(cmd, path=env_path) reads
   executable extensions from the PARENT process PATHEXT, not the MCP
   subprocess env — a stdio MCP config supplying both PATH and PATHEXT
   could fail to resolve a command its own env can locate, and startup
   then got a bare command name. On Windows, when the first which() call
   misses and the config env carries PATHEXT (any key casing), retry the
   resolution with the config's PATHEXT temporarily applied.

2. skills/ + optional-skills/ (NousResearch#50606): 42 SKILL.md files that declare
   platforms: [.., windows] used python3 in their command examples.
   python3 does not exist on native Windows (the toolchain probe in the
   system prompt reports python3=missing), so every copy-pasted example
   burned a failed agent turn before self-correction. Replaced the
   command word python3 -> python (python3-config / python3.x version
   strings untouched). python is the spelling that exists in every
   Hermes-managed environment (Windows native, uv-managed venvs on all
   three OSes); agents on POSIX hosts additionally see the probed
   toolchain line and adapt either way.
The 3-sentence identical-edit message was snapshot-asserted verbatim in
two tests. House style avoids exact-string change-detector assertions;
both tests now import the constant from tools/fuzzy_match so rewording
the message can't silently break them.
…hema

skill_manage's patch action uses the same fuzzy_find_and_replace engine
as the file patch tool and surfaces the identical-strings error verbatim
— and unlike the file path it has NO is_already_applied no-op rescue, so
identical old/new ALWAYS errors there. Mirror the new_string description
so the schema warns before the error fires (sibling-site parity with
tools/file_tools.py PATCH_SCHEMA).
The apply phase already skips a hunk whose -/+ lines are identical
(patch_parser.py '(search_lines == replace_lines): continue'), but the
validation phase lacked the guard: such a hunk reached
fuzzy_find_and_replace, whose identical-strings error names
old_string/new_string — parameters that don't exist in patch mode — and
failed the whole atomic patch that apply would have accepted. Mirror
the apply-phase skip in validation; regression test drives a mixed
degenerate+live patch end-to-end (short text dodges the
is_already_applied >=8-char rescue).
…thon (NousResearch#84452)

* fix(windows): SSH ControlMaster gating + stop hijacking the user's python

Two Windows environment-integrity fixes:

1. tools/environments/ssh.py (NousResearch#73927): Windows OpenSSH has no
   Unix-domain-socket ControlMaster support, so unconditionally passing
   ControlPath/ControlMaster/ControlPersist failed EVERY tool call on a
   Windows-hosted ssh terminal backend with 'getsockname failed: Not a
   socket'. Gate the three multiplexing options behind a module-level
   _SSH_MULTIPLEX = (os.name != 'nt'); the scp upload path is gated the
   same way. On Windows the backend now works without connection pooling
   (each command a fresh connection); POSIX behavior is unchanged. The
   teardown 'ssh -O exit' is naturally inert because the socket never
   exists on Windows.

2. scripts/install.ps1 (NousResearch#83797): the installer put the whole
   venv\Scripts directory on the user PATH, which contains python.exe /
   pythonw.exe / pip.exe and so silently hijacked the 'python' command in
   every terminal on the machine — unrelated projects started resolving
   python to Hermes' runtime interpreter. Now copy only the launchers
   (hermes.exe, hermes-acp.exe) into a dedicated $InstallDir\bin and put
   THAT on PATH. Existing installs are migrated: the legacy venv\Scripts
   entry is stripped from the user PATH on the next install/update. The
   new bin dir is under $InstallDir (…\hermes-agent), which the uninstall
   PATH sweep already matches via its \hermes-agent marker.

Updated the stale hermes_cli/update_cmd.py docstring that described the
old venv\Scripts-on-PATH layout.

Tests: SSH ControlMaster gating pinned both directions (multiplex on →
flags present; off → absent but BatchMode/StrictHostKeyChecking retained).
install.ps1 parses clean via the PowerShell AST parser.

* docs: update windows-native install docs for the bin\ launcher layout

CI (test_windows_native_docs) pins the docs and installer to the same
PATH layout. The NousResearch#83797 fix moved the PATH entry from venv\Scripts to a
dedicated $InstallDir\bin holding only the hermes launchers, so update
the Windows-native guide to match: PATH-after-install section, the
install-steps list, the directory-layout table, the Get-Command
verification line, and the 'command not found' pitfall. Test now asserts
the bin\ layout and guards against a regression back to venv\Scripts on
PATH.

* fix: keep install.ps1 pure ASCII (PowerShell 5.1 codepage safety)

The two comments I added in the NousResearch#83797 PATH-hijack fix used em-dashes,
tripping tests/test_install_ps1_ascii_only.py — Windows PowerShell 5.1
reads a BOM-less .ps1 in the system ANSI codepage (not UTF-8), so a
non-ASCII byte can misdecode into a stray quote and desync the parser
(issues NousResearch#66994/NousResearch#67000). Replace the em-dashes with ASCII '--'.
# Conflicts:
#	contributors/emails/[email protected]
#	gateway/platforms/api_server.py
#	gateway/run.py
#	hermes_cli/web_routers/sessions.py
#	hermes_state.py
#	tests/hermes_cli/test_web_server.py
#	tests/test_tui_gateway_server.py
#	tui_gateway/methods_prompt.py
@vashkartik
vashkartik requested a review from vectorcmd as a code owner August 12, 2026 10:14

@vectorcmd vectorcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code-owner approval of verified green head 2d12e5d.

@vashkartik
vashkartik merged commit c494b92 into ace/patches Aug 12, 2026
99 of 107 checks passed
@vashkartik
vashkartik deleted the automation/hermes-nightly-sync branch August 12, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.