Commit Graph

1351 Commits (812236bff852a74a9fbdec4725ac58faae94de56)

Author SHA1 Message Date
Teknium 3d14f01fd6 fix(desktop): debounce per-keystroke draft persistence writes
The salvaged draft-persistence effect wrote to localStorage on every
keystroke — the composer's per-keystroke path was deliberately slimmed
down previously, so debounce the write (400ms) and flush pending text on
scope change/unmount so a fast session switch can't drop trailing
keystrokes. Also add AUTHOR_MAP entry for the salvaged commit.
2026-06-10 22:34:30 -05:00
Teknium 0a5762c78d fix(web): genericize free-MCP client identity per telemetry policy
Replace the hermes-identifying clientInfo/User-Agent/session-id prefix on
the keyless Parallel Search MCP path with a neutral 'mcp-web-client'
identity. Project policy forbids third-party usage attribution without an
explicit user opt-in (see telemetry PR policy); MCP requires a clientInfo,
so a generic one satisfies the spec without attributing traffic.

Also adds the contributor AUTHOR_MAP entry and refreshes uv.lock against
current main (parallel-web 0.6.0).
2026-06-10 19:54:38 -07:00
teknium1 2d75833abe chore(release): map ianculling for #36087 salvage 2026-06-10 12:39:44 -07:00
brooklyn! 6de3963e37
fix(desktop): keep model runtime state per session (#43702)
* fix(desktop): keep model runtime state per session

(cherry picked from commit f72ee87d99ee38cb7b5badeb9a8af869bb92073a)

* fix(desktop): keep footer model state scoped to active session

(cherry picked from commit d91942ebd4671ff857b5c8526dbf133f04782ecb)

* fix(desktop): restore stored runtime when resuming sessions

(cherry picked from commit 32b3793418257617b8da57e26151f079c2620d00)

* fix(desktop): persist live runtime changes for resume

(cherry picked from commit c58467779436dcef44a80ad55b52664752dc0837)

* fix(desktop): persist resumed endpoint runtime

* chore(attribution): map pinguarmy's commit email in AUTHOR_MAP

The salvaged commits on this branch preserve @pinguarmy's authorship
(郝鹏宇 / peterhao@Peters-MacBook-Air.local). Add the mapping so the
check-attribution CI gate resolves the email to the GitHub username.

---------

Co-authored-by: 郝鹏宇 <peterhao@Peters-MacBook-Air.local>
2026-06-10 18:16:50 +00:00
Teknium 07ac185904
fix(ci): exit-4 forensics for vanishing test files in run_tests_parallel.py (#43646)
* fix(ci): append filesystem forensics when a per-file pytest run exhausts exit-4 retries

A PR-added test file (tests/test_iron_proxy.py, PR #30179) repeatedly
failed exactly one CI shard with 'ERROR: file or directory not found'
across 4 runs (including a fresh merge SHA on fresh runners), while the
identical slice passes locally against the same merge commit and a
tree-integrity watcher confirms no sibling test mutates the repo. Three
unrelated branches showed the same one-shard signature the same day.

We currently cannot attribute these because the log only carries
pytest's exit-4 line. This adds a forensics block to the captured
output when exit-4 survives the retry loop:

- does the file exist NOW (post-retries)
- parent dir entry count + similarly-named entries
- git status --porcelain dirty-entry count + first 10 entries

Zero behavior change: rc stays 4, retries unchanged, forensics wrapped
in a broad try/except so they can never mask the failure.

Two new tests cover the exhausted-retries and genuinely-missing paths.

* chore: drop the two forensics tests — ship the runner change only
2026-06-10 10:04:17 -07:00
teknium1 189ffe7362 test: port voice-reply suffix assertions, fix change-detector cap test, add AUTHOR_MAP entry
- Add output_path suffix assertions (.ogg Telegram / .mp3 non-Telegram) to
  _send_voice_reply tests, covering the OGG voice-note path that landed on
  main in ae82eed2b (the PR's third commit was redundant with it).
- Convert test_gemini_default_is_32000 back to an invariant against
  PROVIDER_MAX_TEXT_LENGTH instead of a hardcoded literal.
- Map barronlroth@gmail.com -> barronlroth in scripts/release.py.
2026-06-10 02:57:39 -07:00
Teknium 888bf96025 chore(release): add tomekpanek to AUTHOR_MAP 2026-06-10 00:34:38 -07:00
teknium1 099146fedd chore: add AUTHOR_MAP entry for PR #33958 contributor 2026-06-10 00:18:11 -07:00
teknium 2ce3ae3d16 fix(error-classifier): don't misclassify unsupported-param 400s as context overflow
A GPT-5 model rejecting max_tokens returns a 400 whose message contains the
literal substring 'max_tokens' — one of the _CONTEXT_OVERFLOW_PATTERNS. The 400
path in _classify_400 checked overflow patterns before any request-validation
check (which only existed on the 5xx path), so the parameter error was routed
into the compression loop, re-sent with the same bad param, and ended in
'Cannot compress further' on a tiny context.

Hoist a request-validation guard (unsupported/unknown parameter) above the
context-overflow check in _classify_400. Deliberately excludes the generic
invalid_request_error code, which OpenAI also stamps on real overflow 400s, so
genuine overflows still compress. Pairs with the max_completion_tokens param
fix that stops the bad request at the source.

Also adds AUTHOR_MAP entry for the salvaged PR #13902 commit.
2026-06-09 23:22:10 -07:00
Teknium ab55008631 chore: add AUTHOR_MAP entry for OndrejDrapalik
Maps the salvaged #36781 commit author email to the GitHub login so the
release attribution + CI author check resolve.
2026-06-09 23:21:24 -07:00
teknium 1967c590ed
chore: add AUTHOR_MAP entry for xiaoxinova
Maps xiaoxingitee@gmail.com -> xiaoxinova so the contributor-attribution
CI check passes when PR #42342 (MiniMax-M3 1M context fix) is merged.
2026-06-09 22:35:38 -07:00
Teknium f082b4ec5c
fix(ci): make parallel runner's exit-4 retry robust for newly-added test files (#42994)
The per-file test runner re-runs a file once when pytest exits 4 ("file or
directory not found") while the file exists on disk — a transient seen on
loaded shared CI runners where the planner collects a file (--collect-only
counts its tests) but the per-file subprocess fails to stat it moments later.

A single immediate retry could land in the same brief high-load window and
fail again, and the retry was gated on one Path.exists() check that can itself
be a flaky stat under that load — so a freshly-added test file that LPT pins to
one shard would deterministically red that shard on every run (no actual test
failure; the file just never executes).

- Extract the subprocess spawn/communicate/process-tree-kill logic into a
  shared _spawn_pytest_once() helper (removes ~90 lines of duplication between
  the primary run and the retry).
- Replace the single-shot retry with a bounded backoff loop
  (_EXIT4_RETRY_ATTEMPTS, escalating sleep) that re-runs while the file is
  present on disk.
- Add _file_present() which re-checks existence across a few spaced stats, so a
  single flaky negative stat doesn't wrongly conclude the file is missing. A
  genuinely-missing file (typo/deleted) still fails fast — exit 4 is not
  swallowed when the file truly does not exist.
- Tests: transient-then-pass recovery, genuinely-missing fails fast with no
  retry, give-up after max attempts, and _file_present transient/missing cases.
2026-06-09 21:39:09 -07:00
xxxigm 02f878ec5a
docs(windows): correct native data dir to %LOCALAPPDATA%\hermes (#42856)
* docs(windows): correct native data dir to %LOCALAPPDATA%\hermes

The Windows-native guide claimed a deliberate split where config, auth,
skills, and sessions live under %USERPROFILE%\.hermes. That is not what
the installer does: scripts/install.ps1 sets HERMES_HOME=%LOCALAPPDATA%\hermes,
so data actually lives in %LOCALAPPDATA%\hermes alongside the disposable
install (the hermes-agent\, git\, node\, bin\ subdirectories) — `hermes
config` confirms config.yaml/.env resolve there, not under %USERPROFILE%.

Update the data-layout table, the "split is deliberate" note, the env-var
and uninstall sections to describe the real layout: data and install share
the %LOCALAPPDATA%\hermes root, reinstall only replaces hermes-agent\, and
a full wipe targets %LOCALAPPDATA%\hermes (with %USERPROFILE%\.hermes kept
only as a legacy/WSL cleanup). Mention HERMES_HOME as the override knob.

* docs(windows): fix PATH + bin layout to match installer

The installer adds hermes-agent\venv\Scripts (where hermes.exe lives) to
User PATH and sets HERMES_HOME — not %LOCALAPPDATA%\hermes\bin. The \bin
dir holds Hermes's managed uv.exe, not a hermes.cmd shim. Correct the
install-step list and the data-layout table accordingly.

* fix(install): show real HERMES_HOME path in setup messages

The native Windows installer wrote config/env/skills under $HermesHome
(%LOCALAPPDATA%\hermes) but its success messages claimed ~/.hermes,
which doesn't exist on native Windows. Print the actual paths so a new
user can find their config, .env, and skills.
2026-06-09 14:11:20 -05:00
brooklyn! 8d71c38919
fix(desktop): rebind sessions after websocket reconnect (salvage of #41740) (#43004)
* fix(desktop): rebind sessions after websocket reconnect

* docs(desktop): explain the reconnect-resume guard in use-route-resume

The reconnect fix turns on two subtle conditions with no inline rationale:
`seenGatewayStateRef` suppresses a spurious "became open" on the first effect
run (so a session mounting with the gateway already open doesn't double-resume),
and the `gatewayBecameOpen ||` arm forces a re-resume even when the route looks
`alreadyActive` because the cached runtime id can be stale after the gateway
rebinds/reaps the session. Comment both so the next reader doesn't "simplify"
them back into the original bug. No behavior change.

---------

Co-authored-by: Josh Dow <josh.dow@prepad.io>
2026-06-09 19:01:00 +00:00
brooklyn! ba44de06da
fix(install): self-heal a stuck Electron download (salvage of #42894) (#42998)
* fix(install): self-heal a stuck Electron download on the desktop build

The desktop build downloads Electron (~114MB) from GitHub. A corrupt cached
zip, or a blocked/throttled GitHub release host (the repeating "retrying" log),
hard-failed the install — and install.sh had no recovery at all while
install.ps1 / `hermes desktop` only purged the cache.

All three build paths now escalate on a failed `npm run pack`:
GitHub → purge corrupt electron-*.zip + stale *-unpacked and retry → one retry
via a public Electron mirror (npmmirror.com). @electron/get SHASUM-verifies the
download, and a user-pinned ELECTRON_MIRROR is always respected (never
overridden). Adds a bash clear_electron_build_cache()/_desktop_pack() to mirror
the existing PowerShell/Python helpers.

* test(install): cover the Electron mirror fallback

Verify `hermes desktop` falls back to a mirror when the cache purge finds
nothing, and that a user-pinned ELECTRON_MIRROR is respected (no extra attempt,
not overridden).

* docs(desktop): troubleshoot a stuck Electron download

Document the automatic cache-purge + mirror fallback, how to pin your own
ELECTRON_MIRROR, and how to clear a corrupt cached zip by hand.

* docs(install): correct the Electron mirror trust framing

The mirror-fallback comments and the desktop troubleshooting doc implied
`@electron/get`'s SHASUM check makes the npmmirror.com download safe against
tampering. It doesn't: the SHASUMS256.txt is fetched from the same mirror, so
the check guards against a corrupt/partial download, not a compromised mirror.

Reframe all four surfaces (install.sh, install.ps1, `hermes desktop`, and the
docs) to state the trust trade-off honestly — npmmirror.com is the de-facto
Electron community mirror, we only fall back to it after the canonical GitHub
download fails, and a user-pinned ELECTRON_MIRROR is never overridden. No
behavior change.

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-06-09 18:19:14 +00:00
Philip D'Souza 92dfd70d6a
fix(photon): production hardening for the gRPC-native iMessage channel (#42732)
* fix(photon): override transitive CVEs in the sidecar deps

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

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

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

* chore: add AUTHOR_MAP entry for PhilipAD

---------

Co-authored-by: PhilipAD <philipadsouza@gmail.com>
2026-06-09 11:12:58 -04:00
teknium1 be2f739e9a test(desktop): cover sleep/wake session recovery in use-prompt-actions
Adds three vitest cases for the recovery path: resume+retry on
"session not found", no-resume passthrough on other errors, and
no-resume when there is no stored session id. Also maps the
contributor's commit email in release.py AUTHOR_MAP.
2026-06-09 03:16:59 -07:00
teknium1 520b59db16 fix(tui): use canonical get_fallback_chain for parity + map author
Follow-up to the salvaged fallback-chain fix:
- Replace the hand-rolled fallback loader with the shared
  hermes_cli.fallback_config.get_fallback_chain() helper so the TUI path
  matches HermesCLI and gateway/run.py exactly: fallback_providers stays
  first and keeps order, with distinct legacy fallback_model entries
  merged in after (deduped). Previously the TUI loader picked one key OR
  the other, diverging from CLI/gateway when both were set.
- Update the test to assert the merged canonical semantics.
- Add psionic73 to scripts/release.py AUTHOR_MAP (CI gate).
2026-06-08 22:53:42 -07:00
teknium ba622d44e4 chore(release): add AUTHOR_MAP entry for m4dni5 2026-06-08 22:46:57 -07:00
teknium 2c1aaa9cba fix: keep interactive OAuth setup-token inheriting stdin
The blanket DEVNULL pass muzzled run_oauth_setup_token()'s interactive
'claude setup-token' login, which needs inherited stdin to prompt the
user. Revert that one call and replace the guard's brittle file:line
whitelist with an inline 'noqa: subprocess-stdin' marker that travels
with the code.
2026-06-08 22:46:57 -07:00
m4dni5 bddab61bcb ci: add subprocess stdin= regression check for TUI-context code
scripts/check_subprocess_stdin.py scans agent/, tools/, plugins/, and
tui_gateway/ for subprocess.run() and subprocess.Popen() calls that
don't explicitly set stdin=. Missing stdin= means the child inherits the
parent's fd, which in TUI mode is the JSON-RPC pipe — causing gateway
crashes on stdin EOF.

Exits 0 (pass) or 1 (violations found). Can be run manually or added to
CI. Skips comments, docstring references, and calls that use input= (which
creates its own pipe).

Usage: python scripts/check_subprocess_stdin.py
2026-06-08 22:46:57 -07:00
teknium1 3dcfbbfc49 chore(release): add underthestars-zhy to AUTHOR_MAP
Salvage follow-up for PR #42444 — maps the contributor's commit email
so the changelog generator can attribute the Photon gRPC channel work.
2026-06-08 21:03:58 -07:00
teknium1 c3420d91ad chore: add jooray to AUTHOR_MAP for salvaged simplex PR #27978 2026-06-08 21:03:45 -07:00
teknium1 300371c3f2 chore: add AUTHOR_MAP entry for ruangraung (PR #42308 salvage) 2026-06-08 15:53:16 -07:00
Ray Sun 37561c214b fix(photon): use allowlisted device client_id + validate token before save
Photon now allowlists registered device clients on the device-code
endpoint; the old client_id "hermes-agent" is rejected with
400 invalid_client, breaking the entire login flow. Switch to Photon's
published "photon-cli" device client and send the standard scope.

Also validate the device-flow token against /api/auth/get-session and
/api/projects/ before persisting it, and extract token candidates from
every response shape Photon has used (access_token, accessToken,
data.*, set-auth-token header) so a token that authenticates the
session lookup but is rejected by the project API fails loudly at
login instead of 404ing downstream.

Verified live: request_device_code() now returns 200 + a valid
user_code where "hermes-agent" returned 400 invalid_client.

Salvaged from #34467 by @yanxue06.
2026-06-08 15:52:33 -07:00
Teknium 5e9d7a7661
fix(skills-hub): stop shipping a degenerate index when GitHub taps collapse (#42347)
The Skills Hub lost every api.github.com-backed source — the OpenAI,
Anthropic, HuggingFace, NVIDIA, gstack, Claude Marketplace and Well-Known
tabs all vanished — while ClawHub/skills.sh/LobeHub/browse.sh survived. A
GitHub API rate limit during the docs-deploy crawl zeroed all three
api.github.com sources (github / claude-marketplace / well-known) at once.

Two compounding bugs let the broken index reach the live site:

1. build_skills_index.py wrote the output file BEFORE the health check, so
   even when the github floor (30) tripped and the script exited 2, the
   degenerate file was already on disk. deploy-site.yml then swallowed the
   exit code with `|| echo non-fatal` and extract-skills.py read the partial
   index. Fix: run the health check first, write the file only when healthy,
   exit without writing on failure. Removed the non-fatal swallow in
   deploy-site.yml so a collapse fails the deploy and the last good site
   stays live (Pages serves the previous build).

2. The build-time GitHub listing path returned [] on a 403 rate-limit without
   retrying or flagging it, so a rate-limited crawl looked identical to an
   empty source. Fix: a shared _github_get() helper on GitHubSource with
   retry/backoff (honors Retry-After / X-RateLimit-Reset on 403/429, backs
   off on 5xx + transport errors) and flags is_rate_limited. Routed
   _list_skills_in_repo and _fetch_file_content through it; gave
   ClaudeMarketplaceSource a persistent GitHubSource + is_rate_limited so the
   builder can name the rate limit as the cause instead of '0 results'.

Added tests/scripts/test_build_skills_index_health.py pinning both contracts:
a degenerate crawl exits non-zero and writes no file; a healthy crawl writes
the index with github/claude-marketplace/well-known all present.
2026-06-08 15:21:28 -07:00
kshitijk4poor a1cb84aca9 chore(release): add mnajafian-nv to AUTHOR_MAP
Unblocks #41551 (and any future mnajafian-nv contributions) from the
contributor-attribution check. Maps mnajafian@nvidia.com -> mnajafian-nv.
2026-06-09 02:40:43 +05:30
teknium1 754154a9c2 fix(tests): retry per-file pytest subprocess once on exit-4 when the file exists
The parallel test runner sharded a present, tracked test file
(tests/plugins/platforms/photon/test_inbound.py) onto a slice that then
reported 'file or directory not found' (pytest exit 4) at exec time —
even though the planner had just enumerated the file via --collect-only
('5269 passed, 0 failed' in the same run). On loaded shared CI runners
the per-file subprocess can fail to stat a file the planner already saw;
the deterministic LPT slicer then reproduces it on every rerun because
the same file set lands on the same shard.

Fix: when a per-file run exits 4 AND the file still exists on disk, retry
the subprocess once before surfacing it as a hard failure. This kills the
shard-flake class for everyone, not just this PR.

Does NOT widen the exit-5-is-pass rule — exit 4 on a genuinely missing
file still fails (verified). Retry reuses the same pgroup-kill cleanup as
the primary run so no grandchildren orphan.

Validation: photon dir runs green through scripts/run_tests_parallel.py;
unit-level negative case confirms a nonexistent file still returns rc=4.
2026-06-08 13:38:30 -07:00
Brooklyn Nicholson e88116256c fix(update): scope git fetch to target branch
A bare `git fetch origin` (and `git fetch upstream`) pulls every ref. The
repo carries thousands of auto-generated branches, so on any
non-single-branch checkout the installer's update path and `hermes update`
spend minutes downloading the full branch list — long enough to stall the
desktop installer or trip the follow-up `git pull --ff-only`.

Scope every update-path fetch to the branch we actually compare/merge
against:
- scripts/install.sh: collapse the remote to single-branch and fetch only
  $BRANCH on the "existing install, updating" path.
- hermes_cli/main.py: fetch the resolved branch in the apply path, the
  --check path (upstream + origin), and the fork upstream-sync.

Tracking-ref updates still happen via git's opportunistic refspec, so the
later origin/<branch> rev-parse/rev-list checks are unaffected.

Tests assert the apply-path fetch is branch-scoped and never bare.
2026-06-08 15:24:31 -04:00
teknium1 c78b3e1d3c fix(auth): add Codex OAuth accounts as distinct pool entries
hermes auth add openai-codex now creates an independent
manual:device_code pool entry per account instead of routing through
the singleton _save_codex_tokens save path, which collapsed every
added account into the latest login (the second add overwrote the
first account's singleton-mirrored device_code entry). This is the
add-path half of #39236; PR #39243 (already on this branch) fixes the
re-auth half.

manual:device_code entries refresh from their own token pair
(_sync_codex_entry_from_auth_store only adopts the singleton for
source=="device_code"), so they need no providers.openai-codex
shadow. Adding the first credential marks openai-codex active (the
singleton path did this implicitly) so the setup wizard's
get_active_provider() check still passes; subsequent adds leave the
active provider untouched.

Adds SOURCE_MANUAL_DEVICE_CODE constant and a regression test that two
distinct accounts keep distinct token pairs. Updates two existing add
tests to the pool-only behavior.

Co-authored-by: glesperance <info@glesperance.com>
2026-06-08 11:57:03 -07:00
Teknium abcf996b1f
feat(windows): enable dashboard /chat tab via ConPTY (win_pty_bridge) + tests (#42251)
* feat(windows): enable dashboard chat tab via ConPTY (win_pty_bridge)

Add hermes_cli/win_pty_bridge.py — a pywinpty-backed drop-in for
PtyBridge with the same spawn/read/write/resize/close surface — and
wire it into the web_server PTY import block so Windows picks it up
instead of falling back to None.

pywinpty is already a declared win32 dependency (pyproject.toml).
The ConPTY read path runs inside run_in_executor so the event loop
is never blocked. Spawn/read/write/terminate call shapes are taken
directly from tools/process_registry.py which already exercises the
same pywinpty version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: remove WSL2-only caveat for dashboard chat tab

The chat pane now works on native Windows via the ConPTY bridge added
in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(windows): cover ConPTY bridge + web_server platform-branched import

Companion to the bridge added in the previous commits.  Verified live on
native Windows 11 (pywinpty 2.0.15) against `hermes dashboard`'s
`/api/pty` WebSocket: the spawned `hermes --tui` (node entry.js) renders
through ConPTY, resize escapes reach `setwinsize`, and closing the WS
reaps both the node child and the pywinpty agent with zero orphans.

tests/hermes_cli/test_win_pty_bridge.py
  Mirrors the layout of the existing POSIX test_pty_bridge.py:
  spawn/io/resize/close/env coverage against cmd.exe and python -c,
  plus the cross-platform fallback surface (PtyUnavailableError, the
  off-Windows `spawn -> raises PtyUnavailableError` guard, and the
  load-bearing _clamp() helper that protects setwinsize from garbage
  winsize values out of xterm.js).

tests/hermes_cli/test_web_server_pty_import.py
  Asserts that web_server.PtyBridge resolves to WinPtyBridge on win32
  and to the POSIX PtyBridge on POSIX, that PtyUnavailableError is the
  matching class on each side (so isinstance checks in /api/pty's
  spawn fallback path work), and a source-text check that pins the
  platform-branched import shape so a future refactor can't quietly
  collapse it back to a POSIX-only import.

scripts/release.py
  AUTHOR_MAP entries so CI release-note generation can resolve both
  authors' plain (non-noreply) emails to their GitHub logins.

Co-Authored-By: JoelJJohnson <josephjohnson.joel@gmail.com>
Co-Authored-By: Nea74 <andreas@schwarz-ketsch.de>

---------

Co-authored-by: JoelJJohnson <josephjohnson.joel@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Nea74 <andreas@schwarz-ketsch.de>
2026-06-08 11:32:43 -07:00
teknium1 5916248dc0 chore: add AUTHOR_MAP entry for rbrtbn (salvage #25939) 2026-06-08 11:29:53 -07:00
teknium1 8cb1908e18 chore: map paulb26 in AUTHOR_MAP for #24135 salvage 2026-06-08 07:03:12 -07:00
kshitijk4poor f96eb857a5 chore: add kristianvast to AUTHOR_MAP 2026-06-08 15:16:20 +05:30
teknium1 00c46b8ff9 test(tui): cover heapdump opt-in gate + retention; add AUTHOR_MAP
On-disk vitest coverage for the auto-heapdump disk-safety guard: opt-in
gating (suppressed diagnostics-only path), truthy-spelling acceptance,
manual-trigger passthrough, and the retention prune. Test approach
adapted from #21780 (briandevans) and #21822 (LeonSGP43), reconciled to
the merged gate semantics. Maps alarcritty into AUTHOR_MAP for CI.
2026-06-08 02:20:49 -07:00
xxxigm a5c12f5f59 fix(install): move broken checkout aside instead of deleting it
Review feedback (#40998): `rm -rf` / `Remove-Item -Recurse -Force` on the
install dir is destructive -- a user might still want whatever is there.
Rename the broken checkout to a timestamped `<dir>.broken-<ts>` backup and
re-clone fresh, so nothing is ever deleted. Transient cleanup of a clone
attempt that fails within the same run is left as-is.
2026-06-08 02:18:21 -07:00
xxxigm fc0900d120 fix(install): re-clone interrupted (commit-less) checkout instead of failing
An interrupted previous clone leaves the install dir's .git present but with
no initial commit. rev-parse --is-inside-work-tree and git status both still
succeed there, so the installer entered the update path and ran `git stash`,
which aborts with "You do not have the initial commit yet" and failed the
desktop install at the "Cloning Hermes repository" stage.

- install.ps1: add a `git rev-parse --verify HEAD` probe to the repo-validity
  check so a commit-less checkout is treated as broken and re-cloned fresh.
- install.sh: mirror it at the top of clone_repo() — drop a partial checkout
  with no resolvable HEAD so the fresh-clone path handles it (POSIX parity).

Fixes #40998
2026-06-08 02:18:21 -07:00
teknium1 02a4d66951 fix(auxiliary): retry transient transport error once before fallback (#16587)
A one-off transient transport failure (streaming-close / incomplete
chunked read / 5xx / 408) on an auxiliary LLM call escalated straight to
provider/model fallback (or, for context compression, dropped the summary
and entered cooldown), even when an immediate retry on the same provider
would have succeeded.

Add a single same-target retry at the top of call_llm() and
async_call_llm() — before the existing except-chain — gated on a new
_is_transient_transport_error() that reuses the canonical
_is_connection_error() detector plus a 5xx/408 status check. A second
failure (or any non-transient error: auth, other 4xx, malformed payload)
falls through to first_err and the existing fallback handling unchanged.

This lives in call_llm so every auxiliary task (compression, memory flush,
title generation, session search, vision) shares one transient-retry
surface, rather than each caller re-implementing it. The context
compressor needs no change — it calls call_llm and inherits the retry; its
existing fallback-to-main path (#18458) now composes naturally (retry the
aux model once, then fall back to main only if the retry also fails).

Co-authored-by: ARegalado1 <alberto.regalado@ymail.com>
2026-06-08 01:05:45 -07:00
teknium1 d759c13c09 chore(salvage): lint fix + AUTHOR_MAP for desktop source-folders PR #40272
eslint --fix (import sort + padding-line-between-statements) on sidebar/index.tsx
after cherry-picking @dangelo352's commits; add release.py AUTHOR_MAP entry so
CI doesn't block on the unmapped author email.
2026-06-07 23:44:04 -07:00
teknium1 4d926f248d chore(release): add AUTHOR_MAP entry for rodboev 2026-06-07 22:39:51 -07:00
teknium1 39c4ac3af1 chore(release): add AUTHOR_MAP entry for JimStenstrom 2026-06-07 22:30:02 -07:00
teknium1 240c5d4543 chore: map martin.alca@gmail.com -> draix in AUTHOR_MAP
Salvage follow-up for PR #33221 — the cherry-picked commit is authored
under martin.alca@gmail.com (not the draixagent@gmail.com already mapped),
which would fail the CI author-attribution gate.
2026-06-07 22:22:01 -07:00
teknium1 f5bd09af4b refactor(acp): share interrupt-sentinel prefix, simplify guard
Replace the ACP-local prefix/suffix matcher + helper with a single
startswith() check against INTERRUPT_WAITING_FOR_MODEL_PREFIX, now
defined once in conversation_loop.py where the sentinel is produced.
Keeps the source of truth in one place so the guard cannot drift if
the status string changes. Net -17 LOC in server.py.

Also add lsaether to release.py AUTHOR_MAP.
2026-06-07 22:20:43 -07:00
Teknium 2789bf4e25 fix(auxiliary): route Codex Responses path through shared converter (#5709)
The auxiliary Codex adapter maintained its own chat->Responses conversion
loop that forwarded every non-system message's role verbatim into
Responses input[]. When flush_memories()/compression replayed session
history containing assistant tool_calls + role=tool results, those tool
messages leaked into the request and the Responses API rejected them with
HTTP 400: Invalid value: 'tool'.

Route _CodexCompletionsAdapter.create() through the same shared converter
the main agent transport uses (_chat_messages_to_responses_input), so tool
calls become function_call items and tool results become function_call_output
items with a valid call_id. Single conversion path means no future drift.

Also remove the now-dead _convert_content_for_responses() helper — its only
caller was the private conversion loop this change deletes.

Co-authored-by: ProgramCaiCai <techxacm@gmail.com>
2026-06-07 22:18:31 -07:00
teknium1 b5f7a1f299 chore(release): add basilalshukaili to AUTHOR_MAP 2026-06-07 22:09:45 -07:00
teknium1 0c67d4015f chore(release): map islam666 for as-is salvage batch 2026-06-07 21:50:57 -07:00
teknium1 38d1a414a1 chore: add islam666 to AUTHOR_MAP for salvaged PR #39624 2026-06-07 21:50:25 -07:00
teknium1 9c5d1afbe9 chore: add giladbau to AUTHOR_MAP for salvaged PR #20182 2026-06-07 18:05:58 -07:00
teknium1 1c7ae46f0e chore(release): map AlchemistChaos co-author email for #40135 salvage 2026-06-07 17:29:12 -07:00
Enes Aydın f3af489ec2
install.sh: hint at root-owned npm cache when desktop npm install fails (#39688)
When apps/desktop's `npm ci`/`npm install` fails, install_desktop printed a
single "Desktop workspace npm install failed" line and aborted, leaving the
user with a wall of raw npm output. A common trigger is a root-owned ~/.npm
cache left by an earlier `sudo npm`/`sudo npx`: the non-root install then
cannot write the shared cache, and npm reports it as EEXIST / "File exists"
while the real errno is EACCES (-13) -- so it reads like an installer bug.

Add a targeted remediation hint on that failure path pointing at:

    sudo chown -R "$(id -un)" ~/.npm && npm cache verify

followed by the manual rebuild command. The stage stays a hard failure by
design (a silent skip yields a "complete" install with no app); only the
failure output changes.
2026-06-07 17:55:58 +00:00
teknium1 16786f3bb3 feat(desktop+gateway): remote media relay — attach images/PDFs and display gateway images over the network
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.

Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
  dir and queued via the existing native-image-attach pipeline. Magic-byte
  extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
  structured error codes. Accepts content_base64/filename (canonical) and
  data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
  and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
  base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
  so the two methods and the existing image.attach don't duplicate logic.

Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
  clients can display it. Auth-gated like every /api route, extension
  allowlist + size cap, AND confined to the gateway's own media roots
  (images/screenshots/cache, resolved symlink-safe) so an authed caller can't
  read image-extension files anywhere on disk.

Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
  connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
  markdown-text fetch images over /api/media in remote mode.

Consolidates the competing remote-media PRs (#38876, #40317, #21908, #39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from #38876 is intentionally left out as a
separable feature. TUI file uploads (#40492) remain a separate surface.

Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.

Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.com>
2026-06-07 10:05:53 -07:00
Teknium a317e54935 chore(release): map Dusk1e and LaPhilosophie for approval fail-closed salvage (#33844, #33866, #30964) 2026-06-07 06:21:37 -07:00
teknium1 fa8fd513ea chore(release): add synapsesx to AUTHOR_MAP for #40495 salvage 2026-06-07 05:01:27 -07:00
manishbyatroy 490c486ff6 fix(simplex): accept display name in SIMPLEX_ALLOWED_USERS
SIMPLEX_ALLOWED_USERS silently denied every contact when operators
listed display names instead of numeric contactIds. The SimpleX UI
never surfaces the numeric id, so display names are what operators
naturally put in the env var. _is_user_authorized only compared
source.user_id (the contactId), so the allowlist never matched.

Expand check_ids to include source.user_name for the simplex platform,
mirroring the existing WhatsApp phone-LID aliasing pattern. Adds doc +
setup-prompt clarification and three regression tests.

Salvaged from PR #40393. Adds manishbyatroy to release.py AUTHOR_MAP.
2026-06-07 04:53:22 -07:00
Teknium 4b9862eb7f chore: map bmoore210 author email for PR #40550 salvage 2026-06-07 02:15:23 -07:00
kshitij 3763355f08
chore(release): map singhsanidhya741@gmail.com to sanidhyasin (#41094)
Adds the AUTHOR_MAP entry for the #40403 salvage (model.default_headers
for custom OpenAI-compatible providers, fixes #40033) so contributor_audit
passes when the salvage PR lands.
2026-06-07 01:55:24 -07:00
teknium1 f9ea4927f2 test(tui): cover _terminal_task_cwd remote-backend branches
Adds regression tests for the SSH cwd fix: local backend keeps
host-validated session cwd; non-local backend uses TERMINAL_CWD (or
terminal.cwd config) verbatim without host isdir() validation; sentinel
values fall back to session cwd.
2026-06-06 18:40:43 -07:00
teknium1 6701c611ba chore(release): map jiangkoumo author email for PR #40540 salvage 2026-06-06 18:36:06 -07:00
Teknium 7db7a9462d fix: align test fixture arg order + add zakame to AUTHOR_MAP
Conflict resolution prefixes --workspace web before --silent (preserving
the Termux npm_workspace_args path); update test_cmd_update fixture to match.
Add zakame@zakame.net -> zakame mapping so CI author check passes.
2026-06-06 18:22:20 -07:00
Brooklyn Nicholson 003110c107 fix(ci): map @TheGardenGallery email + drop unused pytest import
- check-attribution: add chilltulpa@gmail.com -> TheGardenGallery to
  AUTHOR_MAP in scripts/release.py (new external contributor via the
  carried-over commits).
- ty: the dashboard back-compat test imported pytest but never used it,
  tripping unresolved-import. Drop the dead import — tests are plain
  functions driving the parser via subprocess, no pytest API needed.
2026-06-06 12:43:28 -05:00
Teknium fd234bad62
fix(install): detect TLS cert-trust failures during npm install on Windows (#40588)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(install): detect TLS cert-trust failures during npm install on Windows

Corporate MITM proxies and missing root CAs surface as 'unable to get
local issuer certificate' while npm (most often Electron's install.js
postinstall) downloads over HTTPS. The installer surfaced this as an
opaque 'desktop workspace npm install failed (exit 1)', so users
misread it as a permissions/admin-rights problem (issue #38016).

Add a shared Show-NpmCertHint detector and route all three npm-install
failure paths (agent-browser global install, browser-tools workspace,
desktop workspace) through it. On a cert error it prints actionable
NODE_EXTRA_CA_CERTS / strict-ssl remediation; on any other failure it
stays silent.
2026-06-06 09:00:15 -07:00
Teknium f8a241e105 fix(delegate): flatten content blocks in live overlay tail + AUTHOR_MAP
Follow-up on the cherry-picked content-block fix. _extract_output_tail
(the live subagent overlay) still used crude str(content), which renders
a "[{'type': 'text'...}]" blob and — worse — mislabels a block-wrapped
"Error: ..." result as is_error=False. Route it through the same
_stringify_tool_content helper so error detection and previews work at
both consumer sites.

- delegate_tool.py: _extract_output_tail uses _stringify_tool_content
- tests: add _extract_output_tail content-block test (error detection +
  clean preview)
- release.py: AUTHOR_MAP entry for randomsnowflake (CI gate)
2026-06-05 23:34:00 -07:00
Teknium 3c231eb397
chore: release v0.16.0 (2026.6.5) (#40206)
The Surface Release — native desktop app, browser admin panel,
remote-gateway connect, Simplified Chinese desktop UI, leaner default
skill set, NVIDIA/skills trusted tap, fuzzy model picker, /undo.

874 commits · 542 PRs · 170 contributors · 399 issues closed.
2026-06-05 17:55:43 -07:00
kshitijk4poor 53bba70854 chore: add ohMyJason to AUTHOR_MAP 2026-06-06 01:04:25 +05:30
teknium1 e837856ecd chore(release): map ViewWay author email for AUTHOR_MAP 2026-06-05 09:10:26 -07:00
Teknium 72eb42d9ec
feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean"

This reverts commit c8e80cd0bf.

* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"

This reverts commit 8a19884bf3.

* chore(install): keep npm ci desktop-build fix after stash revert

The destructive-clean reverts (#38542/#39568) pulled the desktop
workspace install back to bare `npm install`. The npm ci -> npm install
fallback is orthogonal build-correctness (avoids the Windows
workspace-hoisting flake where install reports up-to-date against a
stale marker while node_modules is empty, breaking tsc -b). Preserve it.

* feat(update): settable stash-or-discard for non-interactive local changes

Adds updates.non_interactive_local_changes (stash | discard, default
stash). Governs ONLY non-interactive updates (desktop/chat app, gateway,
--yes) — interactive terminal updates always stash-and-ask, unchanged.

- config.py: new key under existing updates section; _config_version 26->27.
- main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY),
  reads the setting; new _discard_stashed_changes() drops the stash
  (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive).
  Post-pull restore site branches on it; the bail-out and up-to-date
  restores always preserve work.
- web_server.py + apps/desktop settings: exposes it as a stash/discard
  select (Advanced section, In-App Update Local Changes).
- docs + tests (discard drops, stash restores, interactive ignores setting,
  missing section defaults to stash).

* fix(install.ps1): stash/restore instead of reset --hard on Windows update

The PR reverted the destructive update path to stash/restore everywhere
except scripts/install.ps1, whose managed-clone update path still ran
`git reset --hard HEAD` before checkout — silently destroying agent-edited
tracked source on Windows (the same #38542 data-loss class the PR fixes).

- Replace `git reset --hard HEAD` with stash-before-checkout +
  restore-after-checkout, mirroring install.sh. Untracked files are
  included so agent-created dirs (e.g. tinker-atropos/) survive.
- Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made
  the stash necessary; it's also load-bearing for a clean restore).
- Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses
  `git pull --ff-only` so local commits are never clobbered.
- Only prompt to restore when a real console is attached (UserInteractive
  + non-redirected stdin/stdout + ConsoleHost); the desktop Update button
  and bootstrap have no usable console, so they default to restore and
  never hang on Read-Host.
- On restore conflict or a failed update, the stash is preserved with
  recovery instructions — work is never silently dropped.

Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean;
E2E non-conflicting restore applies+drops cleanly with ignored paths
(node_modules) untouched; conflicting restore preserves the stash.

---------

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-06-05 17:30:10 +05:30
teknium1 397d492b3e chore(release): map harjoth.khara@gmail.com → harjothkhara for #38550 salvage 2026-06-05 02:54:32 -07:00
teknium 2069e78b88 chore: add HeLLGURD to release AUTHOR_MAP for PR #39453 salvage 2026-06-04 19:40:46 -07:00
teknium1 facd011b63 chore(release): map youngstar-eth in AUTHOR_MAP for salvage PR #39134 2026-06-04 19:39:07 -07:00
youngstar-eth 338f0b2234 fix(desktop): recover from corrupt Electron cache in bootstrap install (Windows)
Windows counterpart of #39127: scripts/install.ps1 `Install-Desktop` runs
`npm run pack` once and throws on the opaque ENOENT a corrupt cached Electron
download produces, with no recovery. Add `Clear-ElectronBuildCache` plus a
purge-and-retry-once on pack failure, mirroring the install.sh fix: remove the
cached electron-*.zip (%LOCALAPPDATA%\electron\Cache + ELECTRON_CACHE /
electron_config_cache overrides) and stale *-unpacked output, then retry so
@electron/get re-downloads with its own SHASUM verification.

Refs #37544.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:39:07 -07:00
ethernet 80672754a8 fix(docs): update all install instructions everywhere 2026-06-04 21:07:45 -04:00
teknium1 76c7512dbf chore: add Kewe63 gmail to release AUTHOR_MAP 2026-06-04 17:54:59 -07:00
ethernet 36f1cd7dea feat(installer): do shallow clones
no need to get the whole repo history :)
2026-06-04 17:49:16 -07:00
teknium1 0538c5ed19 chore: add dirtyren to AUTHOR_MAP for PR #38177 salvage 2026-06-04 17:42:10 -07:00
Teknium fe4e327bb5 chore: add Kewe63 to release AUTHOR_MAP 2026-06-04 17:40:33 -07:00
Ali Zakaee 454d6cbe52 fix(telegram): finalize sealed overflow chunk so split streamed replies render formatting
The existing-message overflow split path in stream_consumer.run() sealed the
first chunk via _send_or_edit(chunk) (finalize=False) then reset _message_id
to None — so that chunk was never edited again and never received the adapter's
final rich-text pass. On Telegram, MarkdownV2 formatting is applied on the
finalize edit, so early split messages of a long multi-part streamed reply
rendered raw markdown (##, **bold**, code fences) while only the last chunk
rendered correctly.

Fix: seal the overflow chunk with finalize=True so it gets its final
formatting pass before _message_id is cleared.

Salvaged from #32609 (the streaming-format portion only; the PR's send_draft
parse_mode change is already superseded on main, and its media-roots change
conflicts with the current denylist + recency-window delivery model).
2026-06-04 17:11:12 -07:00
teknium1 d1367355d5 chore(release): map jeffrobodie@gmail.com -> jeffrobodie-glitch for salvage 2026-06-04 12:18:38 -04:00
worlldz 081694c111 fix(kanban): isolate board override per concurrent call 2026-06-04 07:39:53 -07:00
teknium1 dd4ba4c2c4 fix(vision): cap pixel dimensions proactively at embed time + declare Pillow
Follow-up to the salvaged #37727. That PR fixed the reactive recovery path
(classifier + post-failure shrinker) but left the PROACTIVE embed-time guard
in vision_tools byte-only — a tall small-byte screenshot (e.g. 1200x12000 at
0.06 MB) still baked into immutable history un-resized, relying on a failed
round-trip to trigger reactive shrink.

- vision_tools: add _image_exceeds_dimension() + _EMBED_MAX_DIMENSION (7900px);
  the embed-time cap now fires on bytes OR pixels and passes max_dimension to
  the resizer, so tall small-byte images are shrunk before they're embedded.
- vision_tools: best-effort lazy-install of Pillow (tool.vision) in the resize
  ImportError fallback so the soft dep self-heals (respects allow_lazy_installs).
- error_classifier: add two more Anthropic dimension-cap wording variants.
- pyproject + lazy_deps: declare Pillow as the [vision] extra / tool.vision
  lazy dep (it was undeclared everywhere; without it ALL resize recovery no-ops).
- tests: cover _image_exceeds_dimension (tall/small/edge/no-Pillow/corrupt).

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-04 06:16:45 -07:00
teknium1 7314757876 refactor(feishu): slim meeting-invite parser; add AUTHOR_MAP entry
Collapse the payload-shape normalization helpers into one _as_dict and
drop unused dataclass fields (user_type/user_role, duplicate id, bot) on
the meeting-invite handler. Module 274->212 LOC, behavior unchanged.

Add zhaolei.vc@bytedance.com -> zhaoleibd to release.py AUTHOR_MAP.
2026-06-04 06:15:23 -07:00
teknium1 643181b346 chore: add scubamount to AUTHOR_MAP for salvaged PR #37616 2026-06-04 05:46:13 -07:00
teknium1 c14e6b4edf chore(release): map ashishpatel26 author email for salvage 2026-06-04 05:38:12 -07:00
teknium1 5f199e610b chore(release): add AUTHOR_MAP entry for solaitken 2026-06-04 05:35:43 -07:00
alt-glitch aeec88c77f fix(installer): symlink bundled node/npm into command bin dir for FHS root installs
Root installs on Linux (FHS layout, #15608) put the `hermes` command in
`/usr/local/bin` (on PATH) but symlinked the bundled node/npm/npx into
`~/.local/bin`, which isn't on PATH for a stock root shell. `node`/`npm`
were 'command not found' and `hermes dashboard` failed with 'npm is not
available' because its build-on-demand fallback couldn't find npm.

Fix: `install_node()` now symlinks into `get_command_link_dir()` — the same
helper the `hermes` command link already uses — so node/npm/npx land
wherever the command does (`/usr/local/bin` on FHS root, `~/.local/bin`
otherwise, `$PREFIX/bin` on Termux). Non-root and Termux installs are
unchanged.

Also fixes:
- `scripts/lib/node-bootstrap.sh`: adds `_nb_get_link_dir()` mirroring
  the same root/Termux/user logic for the standalone bootstrap path
  (used by `hermes update`, TUI node bootstrap, etc.)
- `hermes_cli/uninstall.py`: `remove_node_symlinks()` now checks all
  candidate directories (`~/.local/bin`, `/usr/local/bin`, `$PREFIX/bin`)
  so root FHS uninstalls don't leave orphan symlinks

Regression from #15608, which created the FHS path for the command but
left `install_node` pointed at the legacy user-local dir.
2026-06-04 02:31:49 -07:00
Siddharth Balyan f31c950182
refactor(supermemory): session-level ingest + kebab aliases (salvaged from #32487) (#38756)
* refactor(supermemory): session-level conversation ingest + kebab tool aliases

Salvaged from #32487 (by @MaheshtheDev), rebased onto current main.

- sync_turn now buffers cleaned turns; the full session is ingested once
  at session end / switch / shutdown via the conversations endpoint
- ingest_conversation() accepts and forwards functional document metadata
  (type, session_id, message_count, partial)
- register kebab-case tool aliases (supermemory-save/search/forget/profile)
  alongside the snake_case names
- README + docs (EN/zh-Hans) updated for the simplified session model

Source/vendor-attribution removed per project policy (no telemetry):
dropped x-sm-source header, sm_source metadata, and sm_capture_mode tags.
Preserved the post-branch atomic_json_write(mode=0o600) hardening that the
PR's stale base had reverted. Updated provider tests for the new behavior
and added maheshthedev@gmail.com to release.py AUTHOR_MAP.

Co-authored-by: alt-glitch <balyan.sid@gmail.com>

* feat(supermemory): restore x-sm-source for Spaces routing

Reinstates x-sm-source: hermes (SDK default_headers + conversations POST)
and sm_source: hermes document metadata. Per @Dhravya (Supermemory), this
is a functional routing key, not telemetry: it groups Hermes writes into a
dedicated "Hermes" Space in the Supermemory app so users can filter and
bulk-manage memories per source agent.

sm_capture_mode remains dropped (appears analytics-only; Spaces are routed
by sm_source) pending confirmation. Adds README note + a unit test covering
_merge_metadata sm_source stamping and legacy source->type migration.

---------

Co-authored-by: Mahesh Sanikommu <maheshthedev@gmail.com>
2026-06-04 11:50:02 +05:30
Teknium 40420a619b
fix(desktop): attachments on Enter, IME composition, scroll, fetchJson resets (salvage #38502) (#38677)
* fix(desktop): critical fixes — attachments, IME composition, scroll, fetchJson

DC2: Pass attachments to onSubmit() on direct Enter submit and call
clearComposerAttachments().  Previously attachments were silently
dropped — only text was sent while attachment pills remained visible.

DH1: Add 'open' to ThinkingDisclosure ResizeObserver effect deps.
When the disclosure toggles, refs point to new DOM but the observer
wasn't reattached, breaking live-scroll preview after expand/collapse
and leaking detached DOM nodes.

DH3+DH4: Add composition tracking via composingRef (set by
compositionstart/compositionend).  Guards handleEditorInput (skip
preedit state writes), handleEditorKeyDown (prefer composingRef over
unreliable isComposing), and form onSubmit (prevent IME Enter from
triggering submission).  Fixes IME Enter message splitting and preedit
text leaking into app state on CJK input.

DH6: Add res.on('error', reject) to fetchJson response stream.
Without this, a TCP reset mid-transfer left the promise hanging forever,
freezing the desktop UI.

All TypeScript compiles cleanly.

* chore: add copii.list@gmail.com to AUTHOR_MAP (stremtec)

* fix(desktop): prevent scroll snap-back during streaming, atomic config writes

DH2: Defer pinToBottom() in useLayoutEffect to rAF so that browser
scroll/wheel events from the current frame are processed first.
Previously an immediate pinToBottom() could snap the viewport back
to bottom against the user's trackpad scroll-up intent during
streaming — the wheel event hadn't fired yet so stickyBottomRef was
still true.

DH7: Add writeFileAtomic() helper (write to .tmp then rename) and
use it in writeDesktopConnectionConfig, writeDesktopUpdateConfig,
and writeBootstrapMarker.  Prevents partial writes on crash/power
loss that would corrupt JSON config files, requiring manual repair.

* fix(desktop): guard nativeTheme listener from duplicates, invalidate connection config cache

DM9: Guard nativeTheme.on('updated') with a one-shot flag so that
multiple createWindow() calls (e.g. macOS activate after all windows
closed) don't accumulate duplicate listeners on the process-wide
singleton.

DM3: Add mtime-based cache invalidation to readDesktopConnectionConfig.
Previously the cache was populated once and never invalidated — if an
external tool modified connection.json, the desktop ignored the change
until restart.  Now re-reads when the file's mtime differs.

* fix(desktop): widen fetchJson res.on('error') to sibling fetch + sort JSX props

Follow-up to salvaged #38502:
- resourceBufferFromUrl had the same mid-stream-reset hang class as
  fetchJson (req.on('error') present, res.on('error') missing). Add the
  response-stream error handler so a TCP reset during body read rejects
  instead of leaving the promise unsettled.
- Sort the new onComposition* JSX props to satisfy perfectionist/sort-jsx-props
  (was an introduced eslint error in the composer).

---------

Co-authored-by: asill-livestream <copii.list@gmail.com>
2026-06-03 23:38:58 -05:00
Ben Barclay 04d620d91f
fix(docker): run config migrations during container boot (salvage #35508) (#36627)
Salvage of #35508 (@dchenk), rebased onto current main. Resolved the
tests/tools/test_stage2_hook_puid_pgid.py conflict (kept both the
envdir-creation regression test on main and the new config-migration
tests).

Docker image upgrades replace code under $INSTALL_DIR but preserve
$HERMES_HOME on the mounted volume, so the persisted config.yaml never
received the schema migrations that non-Docker `hermes update` runs
(#35406). This adds scripts/docker_config_migrate.py, invoked from
stage2-hook after first-boot seeding and before gateway services start:
it backs up config.yaml + .env, runs migrate_config(interactive=False),
and honors HERMES_SKIP_CONFIG_MIGRATION=1 for manual control.

Also fixes a latent bug in check_config_version(): it called load_config()
which deep-merges DEFAULT_CONFIG, so a legacy config with no raw
_config_version falsely reported as already-current. It now reads the raw
on-disk file so legacy configs are correctly detected for migration.

Differs from #35508 as submitted (Option B cleanup): dropped the
`_config_version` line added to cli-config.yaml.example and removed the
accompanying test_cli_config_example_declares_latest_version change-detector
test. The example is a copy-template and has no business asserting a schema
version; check_config_version() reads the user's real config.yaml, not the
example. This removes a second sync point that drifts on every version bump.

Closes #35508. Fixes #35406.

Co-authored-by: Dmitriy Cherchenko <17372886+dchenk@users.noreply.github.com>
2026-06-04 11:11:27 +10:00
Teknium b0a52d74ac
fix(mcp): resolve ${ENV} in discovery probe so header auth works (#38571)
`hermes mcp add --auth header` built `Authorization: Bearer ${MCP_X_API_KEY}`
and passed it straight to the discovery probe without interpolation, so the
probe sent the literal placeholder and auth-requiring servers (e.g. n8n)
returned 401. Runtime tool loading worked because `_load_mcp_config()`
interpolates, but the four CLI probe call sites (add/test/login/configure)
all used unresolved config.

Resolve `${ENV}` inside `_probe_single_server` via a new
`_resolve_mcp_server_config()` (load_hermes_dotenv + _interpolate_env_vars),
mirroring runtime loading. This covers all four call sites, not just add.

Also strip a leading `Bearer ` from pasted tokens before saving to
`MCP_*_API_KEY`, so a token pasted with the prefix doesn't produce
`Bearer Bearer <jwt>` (also a 401).

Reported with a precise root-cause analysis in #37792.

Co-authored-by: ThyFriendlyFox <116314616+ThyFriendlyFox@users.noreply.github.com>
2026-06-03 17:49:39 -07:00
Teknium 475ecea3d7
fix(install): cap requires-python at <3.14 and pin UV_PYTHON to the venv (#38535)
uv selects the project Python from requires-python and from the UV_PYTHON
env var, both of which override an already-created venv on the next
'uv sync'. With no upper bound on requires-python, an inherited
UV_PYTHON=3.14 (or a fresh distro whose newest interpreter uv auto-picks)
silently recreated the installer's 3.11 venv at 3.14, where Rust-backed
transitives (pydantic-core) have no cp314 wheel and fall back to a maturin
source build that fails. This bit a Windows/WSL user with UV_PYTHON set in
their shell and a fresh WSL-arch box where uv auto-picked 3.14.

Two layers:
- pyproject: requires-python '>=3.11' -> '>=3.11,<3.14' (+ uv lock regen).
  uv now refuses a 3.14 interpreter with a clear error instead of attempting
  the maturin build. Backstop independent of the installer.
- install.sh / install.ps1: pin UV_PYTHON to the venv interpreter after
  creating it (in both the venv step and the deps step, since bootstrap runs
  those stages as separate processes). An inherited UV_PYTHON can no longer
  hijack the sync/pip tiers, so the install just works regardless of shell env.

Verified E2E: hostile UV_PYTHON=3.14 + uv venv --python 3.11 + uv sync now
installs into 3.11 with pydantic-core's 3.11 wheel; without the re-pin the
capped requires-python produces a legible incompatibility error rather than a
cryptic build failure.
2026-06-03 16:45:47 -07:00
Teknium 8a19884bf3
fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)
The stash/restore cycle in the update path was observed to clobber
freshly-pulled source files (apps/desktop/ deletion -> Vite
'[UNRESOLVED_ENTRY] Cannot resolve entry module index.html'). On a
managed clone the user never edits the source tree, so any 'dirty' state
is pure git artifact (CRLF renormalization, npm lockfile churn, files
left behind when a directory was deleted upstream such as
apps/bootstrap-installer/). Stashing that and re-applying it after a pull
is fragile and unnecessary.

- hermes update (hermes_cli/main.py): on a non-fork (managed) clone,
  discard working-tree dirt via reset --hard HEAD + clean -fd instead of
  stash/apply. Forks keep the stash machinery so intentional edits
  survive. Also pin core.autocrlf=false on Windows so the dirt is never
  created (mirrors install.ps1 #38239).
- install.sh: replace the update-path stash/restore dance with a hard
  reset to origin/<branch>; the installer is a managed-only entry point.
- install.sh + install.ps1 desktop stage: prefer 'npm ci' (wipes and
  reinstalls node_modules from the lockfile) over bare 'npm install',
  which can report 'up to date' against a stale marker while node_modules
  is empty -- leaving tsc unresolved so 'npm run pack' fails.

Tests: managed clone cleans instead of stashing; fork still stashes;
existing stash tests force the stash path explicitly.
2026-06-03 16:40:13 -07:00
Brooklyn Nicholson 1dca7c6207 fix(install): require Node >=20.19/22.12 for the desktop build
The "Build desktop app" install step failed with an opaque "exit code 1"
on machines with an old Node, and nothing in the logs explained it.

Reproduced: on Node 20.5.1, `npm run pack`'s `vite build` crashes with

  You are using Node.js 20.5.1. Vite requires Node.js version 20.19+ or 22.12+.
  SyntaxError: The requested module 'node:util' does not provide an
  export named 'styleText'

Vite 8 (rolldown) imports node:util.styleText, which doesn't exist before
Node 20.12, so the build dies before producing the app. The installer's
check_node / Test-Node accepted ANY pre-existing Node with no version
floor, so a too-old system Node was used for the build instead of the
bundled Node 22.

Add a version floor (^20.19 || >=22.12) to check_node (install.sh) and
Test-Node (install.ps1): a too-old system Node is replaced with the
Hermes-managed Node 22 LTS, and the desktop stage re-resolves Node so the
build always runs on a satisfying version. Declare the same range in
apps/desktop/package.json engines.

Verified: build succeeds on Node 22, fails on 20.5.1 with the error above;
the floor logic matches Vite's range across boundary versions (20.18/20.19,
21.x, 22.11/22.12).
2026-06-03 09:19:04 -05:00
Teknium 214b7e070f
fix(install.ps1): handle dirty worktree on Windows update (#38239)
Git for Windows defaults to core.autocrlf=true, which renormalizes the
repo's LF-only text files to CRLF in the working tree. On a managed,
never-user-edited clone this makes tracked files (.envrc, AGENTS.md,
agent/*.py, workflows) show as locally modified, so the update path's
bare git checkout aborts with 'Your local changes would be overwritten
by checkout' and the desktop bootstrap fails at stage=repository.

The bash installer already autostashes before checkout; the PowerShell
path had no dirty-tree handling at all and never pinned autocrlf.

Fix: (1) git reset --hard HEAD before fetch/checkout in the update path
to discard any pre-existing dirt, and (2) pin core.autocrlf=false on both
the update and fresh-clone paths so the dirt is never created again.
2026-06-03 06:45:48 -07:00
teknium1 b98b645f87 chore: regenerate lockfile + map vladkvlchk for salvaged #36978
- Add @testing-library/dom to apps/desktop devDeps in package-lock.json
  so npm ci validates against the manifest change (contributor left the
  lockfile out of the PR intentionally).
- Removes stale 'peer: true' flags now that dom is an explicit devDep.
- AUTHOR_MAP: prostoandrei9@gmail.com -> vladkvlchk (CI author gate).
2026-06-03 05:50:36 -07:00
kshitij 50ba36dcab
chore: add bbednarski9 to AUTHOR_MAP for #29722 salvage (#38189)
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-06-03 05:25:35 -07:00
alt-glitch a1264e9967 fix(matrix): make bang-command resolution robust + fix dead skill-command branch
Follow-up to the salvaged contributor commit:

- Underscore→hyphen tolerance now emits a resolvable token. Previously
  the detect set accepted the hyphenated variant but emit returned the
  raw token, so '!set_home' produced '/set_home' which the dispatcher
  could not resolve. Now emits '/set-home'. Aliases are left as-is — the
  gateway dispatcher canonicalizes them itself.
- Fix dead skill-command branch: skill command keys are stored
  slash-prefixed (e.g. '/arxiv') in get_skill_commands(), but the check
  compared the bare token, so '!arxiv' never normalized. Now compares
  the '/candidate' form, making skill aliases (e.g. !gif-search) work.
- Re-run bang normalization after Matrix reply-fallback stripping so a
  quoted reply whose content is a bang command reaches command parity
  with the slash form.
- Replace silent 'except Exception: pass' with logger.debug(exc_info=True).
- Add AUTHOR_MAP entry for @nepenth.

Tests: +5 (underscore-alias, skill-command branch, quoted-reply bang +
slash parity). 162 Matrix tests pass.
2026-06-03 17:19:27 +05:30
ethernet 43fd63b4b5 fix(windows): rip out unused submodule support in installer & docker & docs
we have no submodules anymore, so #37702 was kinda right, but we can just delete it entirely.
2026-06-03 03:01:37 -07:00
teknium1 1e047677a5 chore: add leonardsellem to AUTHOR_MAP for PR #37405 2026-06-02 18:29:08 -07:00
ethernet 46e513ef51 fix(desktop): configure Linux Electron sandbox helper
Electron's chrome-sandbox helper must be root:root 4755 on Linux or the
sandboxed renderer aborts before the desktop app starts. The existing
installer only searched for macOS .app bundles, so a successful Linux
build was reported as missing.

Changes:
- Add _desktop_linux_sandbox_fixup() to hermes_cli/main.py, called
  before launching a packaged desktop app on Linux.
- Use lstat() + S_ISREG check to reject symlinks — chown/chmod on a
  symlink target would set SUID on an arbitrary path.
- Update install.sh to recognize Linux unpacked artifacts and configure
  chrome-sandbox with proper error handling (the original PR silently
  ignored chown/chmod failures).
- Add regression tests: normal fixup flow, symlink rejection, and
  already-configured skip path.

Closes #37529 (rebased, merge conflicts resolved, copilot review
feedback addressed).
2026-06-02 20:30:13 -04:00
ethernet 4df280d511 refactor(uv): single managed-uv path, delete fts5 installer escalation
Replace the multi-path UV resolution chain (PATH probing, conda guards,
5-location trust ordering, temp-dir fallback installs) with a single
managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs
uv resolves it from that one location; if missing, ensure_uv()
bootstraps it via the official standalone installer.

Key changes:

- New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(),
  ensure_uv() (returns (path, freshly_bootstrapped) tuple),
  update_managed_uv(), rebuild_venv(), installer internals.
- hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(),
  add venv rebuild on first-time managed uv bootstrap, update_managed_uv
  before dep install on all 3 update paths.
- scripts/install.sh: install_uv() always installs to
  $HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5,
  _reinstall_python_with_fts5, _warn_no_fts5 (61 lines).
  Managed uv always installs current Python with FTS5.
- scripts/install.ps1: Install-Uv always installs to
  $HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first.
- hermes_state.py: simplified FTS5 warning now suggests 'hermes update'
  as the fix instead of blaming install method.
- tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv
  fixture in test_cmd_update.py.

Closes #37605, Closes #37622
2026-06-02 20:29:54 -04:00
Fearvox 7982560845 fix(release): add fearvox1015@gmail.com -> Fearvox to AUTHOR_MAP
The check-attribution CI job on #37679 failed because the commit
author email nolan@0xvox.com (a local git config mistake on this
machine) is not in scripts/release.py AUTHOR_MAP. The commit
itself is now re-authored to fearvox1015@gmail.com, and this
follow-up adds the entry to AUTHOR_MAP so any future commits
authored from this email also pass the check.
2026-06-02 17:27:44 -07:00
teknium1 082025abcd fix(gateway): route /background result media by type
Background-task (/background, /btw) result media now routes to the
type-specific sender — TTS clip → voice bubble, video → send_video,
image → send_image_file — instead of forcing everything through
send_document. Mirrors the streaming + kanban delivery paths and
reuses base.should_send_media_as_audio for the Telegram OGG nuance.

Co-authored-by: LJ Li <liliangjya@gmail.com>
Co-authored-by: Kolektori <256073454+Kolektori@users.noreply.github.com>
2026-06-02 16:55:25 -07:00
teknium1 34468ed0d4 fix: normalize terminalBackground default and drop unrelated lockfile churn
Follow-up to the salvaged terminalBackground commit:
- align the CSS-var fallback and type doc to the runtime default (#000000)
- revert web/package-lock.json to main (the original commit stripped peer
  flags as an npm-version artifact, unrelated to the feature)
2026-06-01 20:13:56 -07:00
kyssta-exe d4b533de4e fix: batch of small robustness/correctness fixes from @kyssta-exe
Salvages 8 distinct fixes from a batch of PRs by @kyssta-exe, reapplied
onto current main (original branches were stale) with a few refinements.

- cron(jobs.py): load_jobs() validates top-level JSON shape — a bare
  list auto-repairs into the {"jobs": [...]} dict; scalars/null raise a
  clear RuntimeError instead of an uncaught AttributeError that took
  down the whole cron subsystem (#37065, closes #36867).
- web(web_server.py): close the per-action log file handle after Popen
  so the parent stops leaking one fd per spawned action (#36843).
- web(web_server.py): DELETE /api/env returns 400 for invalid key names
  instead of a misleading 500, mirroring PUT /api/env (#36840).
- gateway(gateway.py): read /proc/<pid>/cmdline inside a with-block so
  the fd is released immediately instead of relying on GC (#36804).
- web-tools(web_tools.py): include "xai" in check_web_api_key() so a
  configured X.AI web backend reports as available (#36802).
- compression(conversation_compression.py): mark the feasibility check
  done only after it completes, and default the gate to "not checked"
  if the attribute is missing (#36803).
- completion(completion.py): replace `ls` with directory globbing in the
  generated bash/zsh/fish profile listers — handles names with spaces
  and skips non-directory entries (#36806).
- terminal-tool(terminal_tool.py): drop a duplicate `import threading`
  (#36808).
- claw(claw.py): the migrate recommendation now points at the real
  `hermes gateway stop` command instead of the non-existent
  `hermes stop` (#36795, #36796, closes #36771).
- tests: guard against a leaked HERMES_CRON_SESSION breaking gateway
  approval tests — add it to the hermetic conftest unset list (root
  cause, protects every test) and pop it in the affected test's
  setup_method (#36796).

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-01 19:51:03 -07:00
Trevin Chow d967e74427 chore: add contributor attribution mapping 2026-06-01 18:52:05 -07:00
Ben Barclay ddc22866a3
chore(release): add whyhkzk to AUTHOR_MAP for PR #32407 (#37121) 2026-06-02 11:41:22 +10:00
firefly 3a8d643d37 chore(release): map caojiguang@gmail.com in AUTHOR_MAP
The fix commit preserves @caojiguang's authorship (from #31853); the
release-notes AUTHOR_MAP gate requires their email to map to a GitHub
username.
2026-06-01 17:31:40 -07:00
teknium1 f1237aa95b chore(release): map maxcz79 author email for AUTHOR_MAP 2026-06-01 16:36:43 -07:00
kshitij a5371b3e68
chore: add benfrank241 to AUTHOR_MAP (#36898)
Maps ben.bartholomew@vectorize.io -> benfrank241 so the contributor
attribution audit passes when their commit lands via #36824.
2026-06-01 16:47:07 +00:00
teknium1 ef3a650f05 chore(release): map Subway2023 for PR #14639 salvage 2026-06-01 03:29:48 -07:00
Teknium 2ed96372ad
feat(skills): blank-slate skills — install --no-skills + opt-out/opt-in (#36228)
* feat(install): --no-skills flag for blank-slate default profile

Add an install-time --no-skills flag so the default ~/.hermes profile can
be created with zero bundled skills, matching what
`hermes profile create --no-skills` already does for named profiles.

The flag writes $HERMES_HOME/.no-bundled-skills and skips the install-time
seed. sync_skills() now honors that marker with an early return
(skipped_opt_out=True), so neither the installer, a later `hermes update`,
nor a direct sync re-injects bundled skills into a profile that opted out.

Previously the marker was only checked by seed_profile_skills() (named
profiles); the default profile had no opt-out and `hermes update` would
re-seed it every time.

Tests: TestNoBundledSkillsOptOut covers marker-present (no-op) and
marker-absent (normal seed) paths.

* feat(skills): hermes skills opt-out / opt-in for existing profiles

Adds an interactive counterpart to the install-time --no-skills flag so
an already-installed profile (default or named) can toggle the
.no-bundled-skills marker without reinstalling.

- `hermes skills opt-out` writes the marker (stop future seeding). Safe
  by default: nothing on disk is touched.
- `hermes skills opt-out --remove` ALSO deletes already-present bundled
  skills, but ONLY ones that are manifest-tracked AND byte-identical to
  their origin hash. User-edited bundled skills, hub-installed skills, and
  hand-written skills are never removed. Previews + confirms before
  deleting (--yes to skip).
- `hermes skills opt-in [--sync]` removes the marker and optionally
  re-seeds immediately.

Core logic lives in tools/skills_sync.py (set_bundled_skills_opt_out,
is_bundled_skills_opt_out, remove_pristine_bundled_skills) reusing the
existing manifest origin-hash machinery for the safety check.

Tests: TestOptOutToggleAndRemove covers marker toggle idempotency and
proves user-modified + non-bundled skills survive --remove.

* docs: blank-slate skills — install --no-skills + opt-out/opt-in

- features/skills.md: new 'Starting with a blank slate' section covering
  the install flag, profile-create flag, and runtime opt-out/opt-in, with
  a safe-by-default note.
- reference/cli-commands.md: document the new skills opt-out / opt-in
  subcommands + examples.
- reference/profile-commands.md: fix the marker filename (was .no-skills,
  actually .no-bundled-skills) and cross-link the runtime commands.

Validated with a full docusaurus build (exit 0); the three edited pages
compile clean with no new warnings.
2026-06-01 02:57:57 -07:00
Teknium 3f7d1c801d feat(undo): /undo [N] backs up N user turns with prefill + soft-delete
Extends the existing /undo command from a single in-memory exchange
removal into a full rewind: back up N user turns (default 1), soft-delete
the truncated rows in SessionDB (active=0, kept for audit, hidden from
re-prompts and search), notify memory providers, and prefill the composer
with the backed-up message text for editing — CLI and TUI.

Reuses the SessionDB rewind primitives, the on_session_switch(rewound=True)
memory hook, and the TUI command.dispatch prefill payload from SaguaroDev's
#21910 work, wired to /undo [N] instead of a separate /rewind picker.

- cli.py: undo_last(n, prefill) — in-memory truncate + SQLite soft-delete
  + agent surgery (system-prompt invalidate, flush-index reset) + memory
  notify + editable buffer prefill; /undo dispatch parses optional count;
  checkpoint-rollback caller passes prefill=False
- tui_gateway/server.py: command.dispatch undo branch (was rewind) parses
  count, picks Nth-from-last user turn, clamps to oldest
- commands.py: /undo gains [N] args_hint
- tests: rename + expand TUI suite (multi-turn, clamp, invalid-count)
- release.py: AUTHOR_MAP entry for SaguaroDev

Co-authored-by: SaguaroDev <74339271+SaguaroDev@users.noreply.github.com>
2026-06-01 01:22:38 -07:00
kshitijk4poor ec6261ae2f chore(release): add VinciZhu to AUTHOR_MAP for #16721 salvage 2026-06-01 00:00:26 -07:00
Ben Barclay e3998d4714
chore(attribution): map polnikale for PR #35717 (#36273)
Adds nicsequenzy@gmail.com -> polnikale to AUTHOR_MAP so the
check-attribution gate passes for the Playwright headless_shell browser
discovery fix (#35717).
2026-06-01 16:05:06 +10:00
Ben Barclay bdceedf784
fix(docker): chown hermes-owned top-level state files on boot (#35098) (#36236)
The targeted data-volume chown in stage2-hook.sh only covers hermes-owned
*subdirectories*; loose state files living directly under $HERMES_HOME
(auth.json, state.db, gateway.lock, gateway_state.json, …) are missed.
When created or rewritten by `docker exec <container> hermes …` (root
unless `-u` is passed) they land root-owned, and the unprivileged hermes
runtime then hits PermissionError on next startup, producing a gateway
restart loop.

Fix: reset ownership of an explicit allowlist of hermes-owned top-level
files on every boot. The list mirrors the top-level file entries of
hermes_cli.profile_distribution.USER_OWNED_EXCLUDE plus the runtime lock
files.

This uses a targeted allowlist rather than the originally-proposed blanket
`find $HERMES_HOME -maxdepth 1 -user root` sweep, preserving the
targeted-ownership contract from #19788 / PR #19795: a bind-mounted
$HERMES_HOME may contain host-owned files Hermes does not manage, and
those must never be chowned. Verified end-to-end: allowlisted root-owned
files are reset to hermes on restart while a non-allowlisted host file
keeps its root ownership.

Co-authored-by: x1am1 <2663402852@qq.com>
2026-06-01 14:38:08 +10:00
brooklyn! 79f7e7a1e9
fix(desktop): make locally-built macOS app relaunchable after in-place self-update (#36198)
On macOS the desktop app is built locally and ad-hoc signed (no Developer ID
on the user's machine). An ad-hoc bundle has no stable Designated Requirement,
so when the self-updater rebuilds it in place with a fresh build (new cdhash)
— plus the com.apple.quarantine flag inherited from the downloaded installer
process chain — Gatekeeper/LaunchServices treats the changed code as tampering
and macOS reports "Hermes is damaged and can't be opened," and the app fails to
relaunch. First launch works (fresh registration); the in-place update relaunch
is what breaks.

Fix: after building the desktop app locally, strip quarantine xattrs and
re-apply a clean deep ad-hoc signature (omitting the hardened-runtime flag,
which an ad-hoc build can't satisfy). Applied in both build entry points:
- hermes_cli/main.py cmd_gui (the `hermes desktop --build-only` path the
  updater drives) — so the fix ships via `hermes update` (git), no installer
  re-download needed.
- scripts/install.sh install_desktop (first install) for parity.

Both are no-ops on non-macOS and when a real signing identity (CSC_LINK /
APPLE_SIGNING_IDENTITY) is configured, so signed/notarized builds are untouched.
2026-05-31 21:27:23 -05:00
Ben Barclay e2ee9177f0
chore(attribution): map SiTaggart for PR #35583 (#36189)
Adds me@simontaggart.com → SiTaggart to AUTHOR_MAP so the
check-attribution gate passes for the docker_forward_env empty-secret
fix (#35583, fixes #35580).
2026-06-01 12:16:54 +10:00
brooklyn! fa4ebaa8b5
fix(install): build desktop in 'desktop' stage on macOS/Linux instead of silently skipping (#36134)
The thin installer (apps/bootstrap-installer) drives install.sh stage-by-stage,
each in its own process. The `desktop` stage never called check_node, so the
Hermes-managed Node provisioned earlier (at $HERMES_HOME/node/bin) wasn't on
PATH. install_desktop's `command -v npm` check then failed and the build was
skipped — yet the stage still reported {"ok":true,"skipped":false}, so the
installer showed "Installation Complete" and only failed at the end with
"Couldn't find a built Hermes desktop ... the desktop build step may have been
skipped or failed."

Fix:
- Call check_node in the `desktop` stage (mirrors every other Node-dependent
  stage) so the managed Node is on PATH (or installed).
- Make install_desktop self-provision via check_node and hard-fail (return 1)
  if npm is still unavailable, instead of a silent `return 0`. The desktop
  stage only runs when a build is explicitly requested (--include-desktop), so
  an unavailable toolchain is a real failure, not graceful degradation.

Verified on macOS arm64: the `desktop` stage now builds
release/mac-arm64/Hermes.app, which matches resolve_hermes_desktop_exe, so the
installer's "Launch Hermes" succeeds.
2026-05-31 19:03:10 -05:00
brooklyn! 51c68d4ab1
Add Hermes desktop app (#20059)
* feat: better composer etc

* docs: add desktop and dashboard run instructions

* fix(desktop): address security scan findings

* fix(dashboard): resolve @nous-research/ui path under npm workspaces

The sync-assets prebuild step shelled out to 'cp -r
node_modules/@nous-research/ui/dist/fonts ...' with a path relative
to apps/dashboard/. That works only when the dep is installed
locally in the dashboard workspace, but 'npm install' at the repo
root (the documented setup — see apps/desktop/README.md) hoists
shared deps to the root node_modules under npm workspaces. The
relative cp then fails with 'No such file or directory', sync-assets
exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a
generic 'Web UI build failed' message.

Replace the shell one-liner with scripts/sync-assets.cjs, which
walks up from the dashboard directory looking for node_modules/
@nous-research/ui — working in both the hoisted (workspaces) and
co-located (standalone) layouts. Also guards against a missing
dist/fonts or dist/assets with a clearer error pointing at a
rebuild of the UI package rather than silently copying nothing.

* feat(desktop): support connecting to a remote Hermes backend

Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env
vars that, when set, short-circuit the local-child spawn in
startHermes() and connect the Electron renderer to an already-
running 'hermes dashboard' server reachable over the network.

Motivating use case: WSL2 users who want to run the Hermes core
(agent loop, tools, filesystem access) inside their WSL
distribution while rendering the Electron GUI on native Windows.
Before this change, the desktop app always spawned a local Python
child on the same host as the renderer, which doesn't cross the
WSL/Windows boundary.

The remote path reuses waitForHermes() as a liveness probe
(/api/status is in the backend's public endpoint allowlist), so
the connection is only returned once the backend is actually
ready. WebSocket URL derivation picks ws:// or wss:// based on
the input scheme. URL validation rejects non-http(s) schemes and
requires both env vars together to avoid a half-configured
connection that would silently fall through to the spawn path.

No behaviour change when the env vars are unset — the default
local-spawn flow is untouched.

Typical usage:

  # in WSL2
  hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure

  # on Windows
  set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119
  set HERMES_DESKTOP_REMOTE_TOKEN=<session token>
  set HERMES_DESKTOP_IGNORE_EXISTING=1
  (launch Hermes desktop)

* ci(desktop): automate desktop releases

Add GitHub Actions release channels for signed desktop installers and document the stable/nightly download paths.

* feat: file tabs

* refactor(desktop): tighten right-rail tab close API

Promote closeRightRailTab/closeActiveRightRailTab as the single
public entry point. Drops the activeTabRef + handleCloseDocument
indirection in ChatPreviewRail, the unused $rightRailHasContent
atom, and the legacy dismissFilePreviewTarget alias. -70 LOC.

* feat(desktop): polish composer pill toward reference look

Solid foreground-on-background send/voice-conversation circle (black-on-white
in light, white-on-black in dark) anchors the right edge as the primary CTA
instead of the orange theme primary. Bumps the primary control to 2.125rem so
it visually outranks the ghost mic/plus controls. Opens up the surface padding
(0.625rem x / 0.5rem y) so the input row breathes around its controls, and
nudges the corner radius from 20 to 24px for a slightly pill-ier silhouette.
LiquidGlass distortion is preserved.

* feat(desktop): add startup and onboarding flow

Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours.

* fix(desktop): gate prompts on provider setup

Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors.

* fix(desktop): surface provider onboarding from session warnings

Propagate credential warnings through session runtime info and open desktop onboarding whenever a session reports no usable provider, so unconfigured installs cannot fall through to prompt errors.

* fix(desktop): route gateway provider errors to onboarding

The "No inference provider configured" auth error reaches the renderer through gateway error events, not the prompt.submit promise; the previous patch only caught the latter, so the error toast still surfaced and onboarding never opened.

Also strip credential-shaped env vars from the test:desktop:fresh sandbox so the packaged backend can't see provider keys leaking from the launching shell.

* fix(desktop): use strict runtime check to drive onboarding

setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding.

Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it.

* feat(desktop): OAuth-first onboarding using existing dashboard provider API

Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message.

* fix(desktop): polish onboarding provider list

Reorder OAuth providers so Nous Portal is first, give the segmented Sign in / API key control equal column widths, and replace the engineer-flavored backend names like "Anthropic (Claude API)" / "MiniMax (OAuth)" with friendlier in-app titles. External-CLI providers now show a softer subtitle and an external-link icon instead of a chevron.

* refactor(desktop): split onboarding overlay into store + view

Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom.

* fix(desktop): external CLI providers + center mode tabs

External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge.

* fix(desktop): drop onboarding tabs for an inline link, group device-code waiting state

Replace the Sign in / API key tab pair with an "I have an API key" footer link under the OAuth provider list, with a "Back to sign in" affordance inside the API key form. Group the device-code "Waiting for you to authorize..." status next to the Cancel button so the alignment matches the action.

* refactor(desktop): tighten onboarding store + overlay

Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save.

In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows.

* fix(desktop): mount onboarding from frame 1 to kill the FOUT

Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount.

The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell.

* fix(desktop): top-align empty sessions placeholder

The "Start a chat to build your history." empty state used a min-h-35 grid place-items-center container, which floated the text in a tall dead zone. Render it as a flat paragraph that sits right under the section header like the empty pinned state does.

* refactor(desktop): drop dead boot overlay

Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has).

* fix(desktop): hide pinned/recents sections until first session

A fresh sidebar showed the Pinned and Recent chats headers with floating empty-state copy underneath. Drop both sections (and the now-orphan SidebarEmptySessionState) when there are no sessions yet — they reappear after the first chat. Skeletons during initial load are unchanged.

* feat(gui): route embedded TUI through dashboard gateway (#21979)

Inject HERMES_TUI_GATEWAY_URL into dashboard PTY sessions so embedded ui-tui instances attach to the in-process websocket gateway, with coverage for the new env wiring.

* Add desktop remote gateway settings

Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables.

* feat(gui): first-class Messaging page + gateway menu redesign

- Add Messaging page to the desktop app with per-platform setup,
  status, and inline guidance. Catalog derives from gateway.config
  Platform enum + plugin registry, so every messaging adapter the CLI
  supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp,
  Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu,
  WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up
  without per-platform code.
- New REST endpoints: GET /api/messaging/platforms, PUT and POST
  /test on the same path. Secrets go through the existing .env
  pipeline; enable/disable writes config.yaml.
- Replace gateway statusbar dropdown with a richer panel: status row,
  icon-only restart + system-panel actions, recent activity (with
  timestamps trimmed in display, full text on hover), platform list.
- Auto-poll the messaging page every 6s (paused when hidden) so
  status updates without a manual check.
- Drop Settings / Command Center from the sidebar nav (still
  reachable via shortcuts and the titlebar cog).
- Flatten top corners on Messaging/Skills/Artifacts/Chat panes.
- Share new StatusDot component across messaging + gateway menu.
- Fix gateway/config.py so an explicit platforms.<name>.enabled=false
  in config.yaml is honored when env tokens are present.
- pb-9 on the chat content area for breathing room above the composer.

* Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* pin electron version

* hide application menu on non-mac systems

* interpret compactPreview for non-string vlaues as JSON or an empty string

* fix(desktop): keep composer contenteditable mounted across stacked toggle

The composer rendered {input} inside two different parent fragments
depending on `stacked`. When auto-expand flipped `stacked` (e.g. the
moment typed text wrapped past two lines), React reconciled the two
branches as different positions and unmounted/remounted the
contenteditable. The fresh mount started empty, so any in-flight
characters — most reliably reproduced by holding a key — were lost.

Replace the conditional with a single CSS Grid whose template-areas
swap on `stacked`. The three children (menu, input, controls) keep
stable identities across the toggle; only their grid placement
changes, which the browser handles without React tearing down the
editor.

* refactor(desktop): align install layout with install.ps1 / install.sh

Make the desktop app's runtime layout match what scripts/install.ps1 and
scripts/install.sh produce, so a desktop-only user and a CLI-only user end
up with the same files in the same places and can share one install.

Layout
- ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent  (was: process.resourcesPath/hermes-agent, read-only)
- VENV_ROOT          = HERMES_HOME/hermes-agent/venv  (was: userData/hermes-runtime)
- desktop.log        = HERMES_HOME/logs/desktop.log  (was: userData/desktop.log)
- HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere

The packaged .app/.exe still ships a read-only payload at
process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch
or after an installer-driven upgrade we sync factory -> active, then
provision the venv and run pip install -e . against the active root.

Key behaviors
- Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves
  to the same path resolveHermesHome() picked. Without this, Python falls
  back to ~/.hermes on every platform - fine on mac/linux, a split-state
  bug on Windows where our default is %LOCALAPPDATA%\hermes.
- Detect developer installs by .git presence at ACTIVE; never overwrite
  a user's checkout via factory sync.
- Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks
  pyproject hash + factory version + runtime schema version. depsFresh
  fast-paths when nothing changed.
- Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run
  their local edits, not whatever's under HERMES_HOME.
- Better error messages distinguish "no payload" from "no Python".
- Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes
  exists, so users with prior pip/manual installs aren't orphaned.

pyproject.toml
- Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and
  pywinpty (Windows) to main dependencies. The dashboard backend
  (hermes dashboard) needs them at runtime; the previous lazy-import
  fallback was a footgun for fresh installs.
- Empty the [pty] optional-extra; kept as a no-op back-compat alias for
  any existing pip install hermes-agent[pty] invocations.

Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the
desktop now installs whatever pyproject.toml says, single source of truth.

Files
- apps/desktop/electron/main.cjs:    runtime layout, HERMES_HOME pin,
                                      factory->active sync, marker v4
- apps/desktop/scripts/test-desktop.mjs:  track new venv location
- apps/desktop/README.md:            new Setup, Runtime Bootstrap, and
                                      Debugging sections
- pyproject.toml:                    fastapi/uvicorn/pty backends in main
                                      dependencies; [pty] extra emptied

Tested locally on Windows: npm run dev boots cleanly, sessions land at
the new location, type-check + lint + test:desktop:platforms all pass.
Verified end-to-end on a fresh Win11 VM via dist:win installer.

Known gaps (filed as follow-ups, not in this PR):
- Skills not seeded on packaged installs (sync_skills only runs in
  cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch.
- Git Bash not bundled or detected; agent's terminal tool errors out
  with a useful message but desktop bootstrapper should pre-flight it.
- install.ps1 / install.sh should be decomposed into composable phase
  libraries so the desktop bootstrapper can reuse them as a single
  source of truth across all install surfaces.

* feat(desktop): theme polish, prose chat typography, composer chrome

- DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose
- Composer liquid/radius utilities, thread font parity, tool/thinking cues
- File tree label scale, preview flex, thread retry loading + streaming tests

* feat(desktop): NSIS prereq detection page + auto-install via winget

The packaged Windows installer now detects Python 3.11+ and Git for Windows
at install time and offers to install missing prereqs via winget. Mirrors
the prereq logic scripts/install.ps1 already runs for CLI installs, so
desktop installer users get the same out-of-the-box experience as
install.ps1 users.

Why
- Hermes' terminal tool calls bash.exe directly (tools/environments/
  local.py); on Windows that's Git Bash from Git for Windows. Without it,
  the agent fails on the first terminal() call.
- Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper
  errors out at venv creation.
- Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python
  pre-installed but no Git, so the agent's first terminal call failed
  with "Git Bash isn't installed."
- install.ps1 has had Install-Git + Install-Uv functions for ages. The
  desktop installer was the asymmetric outlier.

How — NSIS prereq page
- New file: apps/desktop/installer/prereq-check.nsh (plugged into
  electron-builder via build.nsis.include)
- Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir
  hook (between the Directory page and InstFiles).
  - Group boxes for Python and Git, each showing detection status.
  - Pre-checked install checkboxes when winget is available.
  - Auto-skips silently if both prereqs are already installed.
  - Falls back to manual download URLs when winget itself is missing.
- Detection:
  - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python
    launcher. Microsoft Store "Python stub" (no py.exe) is correctly
    classified as not-installed.
  - Git: `where git`.
  - winget: `where winget` (Win10 1809+ / Win11 with App Installer).
- Install execution (in customInstall macro):
  - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user
    install, no UAC prompt, output streams to install log.
  - Git: ExecShellWait via Windows ShellExecute. Critical because Git
    always installs per-machine and triggers UAC; ShellExecute preserves
    the foreground focus chain across non-elevated → elevated process
    spawns, so UAC actually comes to the foreground. nsExec::ExecToLog
    breaks the chain because winget runs hidden.
  - Both pass `--disable-interactivity --accept-package-agreements
    --accept-source-agreements` to suppress winget's own dialogs.
- Verification: probes Git's standard install locations via FileExists
  rather than `where git`. NSIS's process inherits PATH at startup, so
  a freshly-installed Git won't be visible to `where` until restart.
- Silent installs (/S) skip the prompts; managed deploys handle prereqs
  out-of-band via Group Policy / Intune.

How — Electron-side safety net
- New findGitBash() in main.cjs, parallel to findSystemPython(). Probes
  the same locations as tools/environments/local.py:_find_bash() so a
  positive result here means the agent's terminal tool will work.
- ensureRuntime now throws a clear, actionable error on Windows when Git
  Bash isn't found, matching the existing "Python 3.11+ is required"
  error path.
- Catches users the NSIS page doesn't: .msi installer users (NSIS prereq
  page doesn't run for MSI), `npm run dev` users, manual installers,
  anyone who unchecked the install boxes on the NSIS prereq page.
- All gated on `IS_WINDOWS`; macOS / Linux unaffected.

NSIS build issue (resolved)
- electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer
  emits "warning 6010: function not referenced" for our page functions
  because Page custom directives don't count as references in its
  static-analysis pass. The functions ARE called at runtime when NSIS
  invokes the page; the optimizer just can't see it statically.
- Set `build.nsis.warningsAsErrors=false` in package.json so this
  spurious warning doesn't fail the build. (Documented option from
  electron-builder's nsisOptions.)

Out of scope (filed for future work)
- MSI prereq detection: Windows Installer custom actions are a different
  mechanism. Enterprise deploys typically handle prereqs via GP/Intune.
- Bundle PortableGit + python-build-standalone in extraResources for
  zero-network installs. ~80MB increase.
- Mac / Linux GUI prereq flows (different installer formats; Xcode CLT
  covers most macOS prereqs already; Linux is per-distro hard).

Files
- apps/desktop/installer/prereq-check.nsh   (new, ~290 lines NSIS)
- apps/desktop/package.json                 (build.nsis.include +
                                              warningsAsErrors)
- apps/desktop/electron/main.cjs            (findGitBash + preflight)
- apps/desktop/README.md                    (Runtime prerequisites
                                              section)

Cross-platform impact
- macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis
  config is ignored entirely; .nsh is dormant.
- npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS.
- scripts/install.ps1, scripts/install.sh: no reference to any new
  files; CLI install paths untouched.
- Hermes CLI / dashboard / gateway: no reference; runtime untouched.
- All checks: node --check on main.cjs and test-desktop.mjs pass;
  npm run test:desktop:platforms 4/4 passing; node --test green.

Tested
- npm run dist:win produces signed .exe and .msi without errors.
- Fresh Win11 VM (Python pre-installed, no Git): prereq page renders,
  Python check shows detected, Git checkbox pre-checked. Click Next →
  Git installs via winget with UAC prompt in foreground.
- After install completes, Hermes launches and the agent's terminal
  tool can run bash commands. Verified Git Bash is detected at
  `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight.

* feat: theme changes, composer tweaks, in app update ux, finesse

* fix(cli): seed bundled skills on dashboard + gateway entrypoints

`sync_skills(quiet=True)` was only being called from inside `cmd_chat`,
which meant `hermes dashboard` (the desktop GUI's backend) and `hermes
gateway` (Telegram/Discord/Slack/etc daemons) never seeded the bundled
skill library into ~/.hermes/skills/.

This surfaced as "No skills found" in the desktop GUI's skills panel on
fresh installs, despite the agent having access to the full bundled
library when invoked via `hermes chat`. scripts/install.ps1 worked
around it by running skills_sync.py as part of Copy-ConfigTemplates,
but that's not part of the desktop installer's bootstrap chain.

Fix
- Extract the skills-sync block from cmd_chat into a module-level
  `_sync_bundled_skills_quietly()` helper.
- Call the helper from cmd_chat (preserving existing behavior),
  cmd_dashboard (after the --status/--stop early-return paths and
  fastapi import check, so we don't run skills_sync on management
  commands or when deps aren't installed), and cmd_gateway.

Why these three entrypoints
- cmd_chat: the user's primary CLI entrypoint
- cmd_dashboard: the desktop GUI's backend; this is what `hermes
  dashboard --tui` invokes when the desktop bootstrapper spawns Hermes
- cmd_gateway: long-running daemons where the user expects the agent
  to have full skill access

Other entrypoints (cmd_config, cmd_doctor, cmd_login, cmd_status,
etc.) are management commands that don't need skill discovery and were
never running skills_sync in the first place — leaving them alone.

Idempotence
- tools/skills_sync.py is manifest-based: skipped skills cost
  milliseconds. Calling it from multiple entrypoints adds no real
  cost, and users running `hermes chat` then `hermes dashboard` get
  two fast no-ops on the second call.

Failure handling
- Helper wraps skills_sync in try/except. Skills are an enhancement,
  not a hard dependency — Hermes runs fine with an empty skills/ dir.

Files
- hermes_cli/main.py:
  + new helper `_sync_bundled_skills_quietly()` at module level
  + cmd_chat: replace inline block with helper call
  + cmd_dashboard: add helper call after fastapi import succeeds
  + cmd_gateway: add helper call before delegating to gateway_command

* feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes

- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no
  tool-accordion). Header derives label from active task; non-active rows fade.
- Replace raw JSON dumps with structured key/value summaries via
  formatToolResultSummary; nested error extraction for clearer failures.
- Fix loaded-session grouping: stitch interleaved assistant/tool iterations
  into one bubble instead of orphaned synthetic messages.
- Stable tool/thinking timers via keyed registry so unmount/scroll doesn't
  reset elapsed counts; gate "running" on real live thread state.
- Reorganize chat-only assistant-ui components under components/chat/.

* fix(desktop): address CodeQL alerts on PR #20059

- settings/helpers.ts: harden setNested against prototype pollution.
  POLLUTING_PATH_PARTS check is now applied at every assignment site
  (loop + leaf) and uses Object.defineProperty so CodeQL can see the
  guard inline rather than via a helper function call.

- lib/markdown-preprocess.ts: rebuild the dangling-fence close regex
  from a fence-char + length instead of marker.replace(...). The marker
  is captured by `(`{3,}|~{3,})` so it can only be backticks or tildes,
  but CodeQL was tracing tainted input text into the RegExp source and
  flagging hostname dots from input as part of the pattern (false
  positive js/incomplete-hostname-regexp on the test fixture URLs).
  Reconstructing from a literal char breaks the dataflow.

- scripts/notarize-artifact.cjs: drop args from the run() rejection
  message. Args carry --key-id / --issuer / key file path; the existing
  outer catch already squashes errors to a generic line, but CodeQL was
  flagging the args.join(' ') as clear-text logging of APPLE_API_KEY_ID.

Composer DOM-text-as-HTML alerts (composer/index.tsx:379, :547) are
already addressed in 4dd9732a9 — innerHTML assignment was replaced with
renderComposerContents which builds DOM via replaceChildren / append
text nodes (no HTML interpretation).

* fix(desktop): inline prototype-pollution guard so CodeQL sees it

CodeQL's dataflow doesn't follow the helper-function guard inside
`safeSet`, so it kept flagging Object.defineProperty as prototype-
polluting. Inline the literal `__proto__`/`constructor`/`prototype`
check at the assignment site to break the dataflow.

Behavior unchanged — same set of disallowed keys, same throw.

* feat(ui-tui): resolve links to readable page titles

Mirror desktop pretty-link behavior in the TUI by resolving HTTP links to page titles with shared caching and safe fetch filters, plus slug-based fallbacks so chat links stay readable even when title fetch fails.

* fix(desktop): drop RegExp from dangling-fence close detection

Previous attempt tried to break the dataflow by reconstructing the
close-fence regex from a literal char + marker.length, but CodeQL still
traced marker.length back to input and kept flagging the test-fixture
URLs as hostname-regex sources (js/incomplete-hostname-regexp).

Replace `new RegExp(...)` + `closeRe.test(body)` with a string-only
hasCloseFenceLine() helper that splits on '\n' and uses ===. No regex
on this path now, so input data can no longer reach a RegExp source.

Behavior preserved: matches lines that are (whitespace + marker +
whitespace), which is what the original `\n[ \t]*${marker}[ \t]*(?=\n|$)`
matched. All 12 markdown-text tests still pass.

* fix(process-registry): suppress windows-footgun false positive on guarded killpg

Keep the existing POSIX-only process-group teardown path, but make the
signal selection explicit via getattr and add an inline windows-footgun
suppression marker on the guarded os.killpg line so the Windows footgun
check no longer blocks CI on this intentionally platform-gated code.

* feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).

* feat(desktop): add ripgrep to NSIS prereq page + polish layout

Add ripgrep as a third (recommended) prereq alongside Python and Git in
the NSIS prereq detection page, and clean up the page layout based on
on-VM testing.

Why ripgrep
- Hermes' search_files tool calls `rg` directly for content + filename
  search (tools/file_operations.py:1382). Falls back to grep/find from
  Git Bash when missing — works but slower and noisier (no .gitignore
  awareness).
- ~5MB winget install via `BurntSushi.ripgrep.MSVC --scope user` — no
  UAC prompt, parallel to how Python installs.
- scripts/install.ps1 already installs ripgrep as part of
  Install-SystemPackages; this brings the desktop installer to parity.

Why "recommended" not "required"
- Python and Git are hard requirements: without them the agent runtime
  or terminal tool refuses to start. The bootstrapper preflight throws.
- ripgrep is a performance enhancement: missing it just means slower
  searches. Page wording reflects this; failure to install is logged
  but doesn't show a MessageBox or block.

Layout polish (response to on-VM screenshot review)
- Wizard header now correctly reads "System Requirements" instead of
  the leftover "Choose Install Location" from the previous page. Set
  via `GetDlgItem $HWNDPARENT 1037/1038` + WM_SETTEXT — the standard
  NSIS pattern for overriding the page header on a custom Page.
- Removed redundant in-body title + verbose intro paragraph; the
  wizard header IS the title now. Body has one short intro line.
- Group boxes tightened to 26u with content positioned just below the
  groupbox title (not top-anchored status + bottom-anchored checkbox
  with empty space in the middle). All three panels + footer fit
  comfortably in 126u, well under the 140u page limit.
- Checkbox labels simplified: dropped "(per-user, no admin prompt)"
  and "(administrator approval required)" suffixes. The footer note
  still calls out UAC for Git when relevant.
- Footer text trimmed to fit cleanly without clipping.

Install order (in customInstall macro)
- Python → ripgrep → Git
- Python and ripgrep are silent and run first; Git's UAC prompt comes
  last so the user's approval interaction isn't interrupted by silent
  activity afterwards.

Skip behavior unchanged
- All three detected → page auto-skips via Abort
- Silent install (/S) → customInstall winget block skips
- User unchecks all → page advances without running winget

Files
- apps/desktop/installer/prereq-check.nsh: ripgrep detection block,
  ripgrep page panel + checkbox, ripgrep customInstall block,
  GetDlgItem header override, layout reflow
- apps/desktop/README.md: Runtime prerequisites section updated to
  list ripgrep as recommended, with manual winget command

* feat(desktop): add model-confirmation step to onboarding

After OAuth/API-key login completes, onboarding now shows a confirmation
card with the curated default model and a Change button before dropping
the user into chat. Closes the gap where the desktop's `model.default`
was empty after first launch and the agent had to fall back to whatever
heuristic happened to fire — leaving users wondering "why am I getting
sonnet-4 when I logged into Nous Portal?"

Why
- Desktop onboarding only persisted credentials, never `model.default`.
  The CLI's `hermes model` command pairs provider + model selection,
  but the desktop's onboarding skipped the model step entirely.
- Result: users saw whichever model the agent's auto-fallback picked,
  unpredictably and undocumented.
- For the BUILD demo we want users to land on the model they expect
  for their provider, with a clear "this is what you're getting" UI
  and a one-click path to change it before chatting.

How
- New `confirming_model` flow status carries the just-authenticated
  provider slug, current default model, label, and a saving flag.
- `completeWithModelConfirm()` runs after credentials succeed: reloads
  env, verifies runtime, fetches /api/model/options to find the curated
  first-model for the provider, persists it via /api/model/set, then
  transitions into `confirming_model`.
- If anything fails (no providers returned, network error), falls
  through to the previous behaviour — onboarding completes without
  the confirm step. Polish, not a hard requirement.
- All four credential paths (device_code OAuth, PKCE OAuth, external
  CLI flow, API key) now use completeWithModelConfirm instead of
  reloadAndConnect.

UI
- `ConfirmingModelPanel` shows: green "<provider> connected" banner,
  card with "Default model: <name>" + Change button, and a "Start
  chatting" CTA that finalises onboarding.
- Reuses the existing `ModelPickerDialog` (the same picker available
  from the chat shell) for the change-model UX. Search, filtering,
  multi-provider listing — all already built.
- Stacking: ModelPickerDialog defaults to z-130, which renders UNDER
  the onboarding overlay (z-1300) and breaks pointer events. Added
  optional `contentClassName` prop to ModelPickerDialog so callers
  can override; onboarding passes `z-[1310]`.

Provider-slug matching
- For OAuth flows: pass `provider.id` directly as the preferred slug.
- For API-key flows: `OPENROUTER_API_KEY` → "openrouter" via env-key
  prefix strip. Also includes the user-visible label as a fallback
  candidate.
- fetchProviderDefaultModel falls back to the first authenticated
  provider in the response if no preferred slug matches — so even a
  miss still surfaces a reasonable default.

Files
- apps/desktop/src/store/onboarding.ts:
  + new `confirming_model` flow variant
  + fetchProviderDefaultModel + completeWithModelConfirm helpers
  + setOnboardingModel (optimistic update + revert on failure)
  + confirmOnboardingModel (finalises onboarding from the card)
  - reloadAndConnect (replaced; the four call sites now go through
    completeWithModelConfirm)
- apps/desktop/src/components/desktop-onboarding-overlay.tsx:
  + ConfirmingModelPanel component
  + new branch in FlowPanel for status `confirming_model`
  + ModelPickerDialog usage with z-[1310] content class
- apps/desktop/src/components/model-picker.tsx:
  + optional `contentClassName` prop on ModelPickerDialog so the
    dialog can be stacked on top of other fixed overlays

Tested
- `npm run type-check` passes
- `npx eslint` clean on touched files
- Live test in `npm run dev`: cleared onboarding cache, walked
  through Nous device-code flow, saw confirm card with curated
  default, clicked Change → ModelPickerDialog rendered above the
  onboarding overlay with working pointer events, picked a different
  model, "Start chatting" persisted to ~/.hermes/config.yaml.

* fix(desktop): suppress generic provider warning in onboarding

Hide the red setup notice when the message is the generic missing-provider guidance, since onboarding already presents provider auth actions. Centralize provider-setup matching across desktop hooks and add coverage for the matcher.

* fix(desktop): add 2u clearance below prereq checkboxes

Group box bottom border was clipping the checkboxes by 1-2px.
Bumped each box height 26u→30u; checkboxes now sit 2u above the bottom border.

* fix(nix): refresh dashboard lockfile hash

Update the web npm deps hash in nix/web.nix to match the committed apps/dashboard/package-lock.json so bb/gui passes the nix lockfile check.

* fix(desktop): install TUI deps in release workflow

Ensure desktop release builds install the standalone ui-tui package before bundling the TUI payload.

* fix(desktop): run release builder from app package

Invoke the desktop builder through the package script so electron-builder uses apps/desktop/package.json.

* fix(desktop): expand release artifact names safely

Build desktop artifact names from workflow version/channel while preserving electron-builder platform macros.

* fix(desktop): use package artifact naming in release workflow

Let electron-builder's desktop package config provide platform-specific artifact extensions while the workflow injects the release version/channel metadata.

* fix(nix): fetch dashboard npm deps from package root

Point the dashboard npm dependency fetch at apps/dashboard so Nix can find the package lockfile after the dashboard move.

* fix(nix): build dashboard from package directory

Set the web package source root to apps/dashboard so npm patch/build phases run beside the dashboard lockfile while keeping apps/shared available as a sibling.

* feat(desktop): render LaTeX math via KaTeX after streaming completes

Add @streamdown/math plugin to the chat markdown renderer.
Inline ($x^2$) and block ($$...$$) math both supported with
singleDollarTextMath enabled. Plugin is gated to non-streaming state
to match the existing pattern for syntax highlighting — math renders
when the message completes, avoiding KaTeX re-render churn during
streaming. KaTeX CSS is imported in styles.css; ~30KB CSS + ~430KB
JS added to the bundle. Smoothness improvements during streaming
deferred to a follow-up.

* perf(desktop): memoize KaTeX renders so math streams without re-rendering

Wrap rehype-katex with a per-equation LRU cache (keyed by
displayMode + source text) and re-enable math during streaming.

Stock @streamdown/math runs rehype-katex on every markdown commit,
so each new token re-katexes every equation in the message. For
math-heavy responses (an equation derived step-by-step) that's
hundreds of ms of wasted work per token and the streaming UI
chokes. With memoization, each equation pays katex.renderToString
exactly once; subsequent tokens re-walk the tree but hit cache for
unchanged equations.

The wrapper mirrors rehype-katex's semantics exactly: same class
detection (language-math, math-inline, math-display), same
<pre>-walk-up for fenced math blocks, same parent.children.splice
replacement, same SKIP traversal, same strict-then-lenient render
strategy with VFile message reporting.

Cached children are structuredCloned on each splice so downstream
rehype plugins or toJsxRuntime can't mutate the cache.

* fix(desktop): declare katex-memo deps directly + drop per-app lockfile

katex-memo.ts (added in 112cad59b) imports hast-util-from-html-isomorphic,
hast-util-to-text, remark-math, katex, and unist-util-visit-parents but
those were never added to apps/desktop/package.json. They were silently
resolving via @streamdown/math at the workspace root, which broke the
moment `npm i --prefix apps/desktop` ran with the per-workspace lockfile
because that install only consults apps/desktop/package.json. Add them
as direct deps, plus unified/vfile/@types/hast for the type imports.

Also delete apps/desktop/package-lock.json — root package.json declares
workspaces: ["apps/*"], so npm manages all lockfile state at the root.
The stale per-app lockfile is what made `npm i --prefix apps/desktop`
diverge from the workspace install in the first place and left an empty
apps/desktop/node_modules/@assistant-ui/ stub that Vite's dep optimizer
then tried (and failed) to open at @assistant-ui/core/dist/internal.js.

* feat(desktop): disable Backdrop noise overlay by default

The noise overlay defaulted to on, which adds a busy speckle layer over
the whole window for every new user. Flip the Leva default to off; the
toggle stays in Backdrop / Noise for anyone who wants it back.

* fix(desktop): polish LaTeX rendering — currency, code blocks, brackets

Five distinct bugs surfaced from a math-heavy stress test:

1. Adjacent code fences glued together. scrubBacktickNoise's
   second-pass regex /``\s*``/g matched the LAST 2 backticks of
   one fence + whitespace + FIRST 2 backticks of the next, collapsing
   two blocks into one. Fixed with lookbehind/lookahead so we only
   match exactly 2 backticks not part of a longer run.

2. Whitespace eaten between fences and following content.
   stripPreviewTargets internally calls .trim() which strips leading/
   trailing whitespace from each split-segment. For segments between
   two fences this collapsed \n\n to '', gluing fence close to next
   block. Fixed by capturing leading/trailing whitespace at the call
   site and restoring it after the transform.

3. Currency dollar signs eaten as math. With singleDollarTextMath:true
   remark-math greedy-matched any pair of $, so '$5 ... $10' became
   one inline math span. Added escapeCurrencyDollars to escape $<digit>
   patterns to \$<digit> in prose segments (not in code). Trade-off:
   math expressions starting with a digit (rare — '$5x = 10$') get
   escaped too. Mirrors the convention in ChatGPT/Claude's UIs.

4. \(...\) and \[...\] LaTeX brackets unsupported. Models often
   emit these instead of $...$ / $$...$$. Added
   rewriteLatexBracketDelimiters preprocessor pass.

5. ```latex / ```tex blocks were being routed to KaTeX via a
   rewrite to ```math. Aligns with GitHub markdown convention:
   ```math = render as math; ```latex / ```tex = LaTeX/TeX
   source code (syntax highlighted, not rendered). Conflating them
   broke teaching/showing-source use cases. MATH_FENCE_LANGUAGES
   pruned to {'math'} only.

Also flipped parseIncompleteMarkdown to true (was !isStreaming) so
the math parser can't see $ inside streaming-but-not-yet-closed code
fences. Shiki was already deferred via defer={isStreaming} so this
doesn't introduce new tokenization cost.

Test: 18/18 existing tests still pass; one test updated to expect
escaped \$ in currency-prose-with-URL case.

* fix(desktop): detect Python via registry/filesystem; pin to 3.11–3.13

Two related fixes for Python detection on Windows:

1. py.exe (Python launcher) is missing from per-user installs that
   didn't check the launcher option, so 'py -3.X --version' alone
   misses real Python installs. User-reported case: clean Win11 +
   official Python.org 3.14 install -> 'where py' returned nothing,
   our installer offered to install Python again. Both NSIS prereq
   page and main.cjs now probe in this order:
     1. py.exe launcher (when present)
     2. PEP 514 registry: HKLM/HKCU\SOFTWARE\Python\PythonCore\<v>\InstallPath
     3. Filesystem: %ProgramFiles%\Python<v>, %LocalAppData%\Programs\Python\Python<v>
   Crucially, we never fall back to running 'python.exe' from PATH
   on Windows — the WindowsApps stub at %LOCALAPPDATA%\Microsoft\
   WindowsApps\python.exe is a redirector that opens the Microsoft
   Store window if no Store Python is installed. Triggering that
   during boot would be terrible UX. Registry/filesystem probes
   never execute the binary.

2. Drop 3.14 from the supported version set. Several Hermes deps
   (notably pywinpty, which carries Rust crates like
   windows_x86_64_msvc) don't yet publish 3.14 wheels. With wheels
   missing, 'pip install -e .' falls back to building from sdist,
   which needs a Rust toolchain — users see 'could not compile
   windows_x86_64_msvc build script' on first run. install.ps1
   sidesteps this by pinning to 3.11 via uv; the desktop installer
   doesn't yet have the same uv-managed-Python pathway, so for now
   we accept 3.11/3.12/3.13 and tell winget to install 3.11 if
   none of those are present. Revisit when the wheel ecosystem
   catches up to 3.14 (~early 2026).

* feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes

- Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring.
- Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d).
- Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing).
- Remove stray merge conflict markers from pyproject.toml optional deps.

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

* fix(title-bar): position sidebar toggle button

* feat(desktop): composer queue — queue many, edit/delete/cancel-edit, Cursor-style

Press Enter while busy with a draft to queue it; with no draft to interrupt
and send the next queued turn. Auto-drains one queued turn each time the
session settles, same as Cursor. Queue persists across reloads so an
interrupted-and-queued turn isn't lost on refresh.

Each queued row supports edit-in-composer (with explicit Save/Cancel),
send-now (↑), and delete. Drain skips only the entry currently being
edited so the rest of the queue keeps flowing.

Queue dequeue is transactional — an entry only leaves the queue after
`prompt.submit` is accepted, so a rejected submit doesn't drop the turn.

Also shrinks the `[interrupted]` marker to a muted one-liner and drops
its assistant footer so it stops looking like a real reply.

* fix(desktop): handle empty usage analytics totals

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

* fix(desktop): address PR review titlebar and usage races

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

* feat(desktop): add MCP settings and live subagent tree

Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands.

Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session.

* fix(desktop): move power-user views out of sidebar

Keep Cron and Profiles available through lower-prominence chrome entry points so the workspace sidebar stays focused on core chat navigation.

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

* refactor(desktop): subagent overlay reads like a live transcript, not a dashboard

Strip the card chrome and rewire /agents to feel like peeking into the
child agent's stream:

- subagents store: single `stream` of typed entries (thinking/tool/progress/
  summary) replaces the parallel notes/thinking/tools arrays. Drop unused
  fields (toolsets, depth, apiCalls, reasoningTokens, sessionId).
- agents view: no OverlayCards, no boxed stream, no per-row borders. Goal +
  status pill + indented stream lines, full row width.
- Group root spawns into "Delegation N" sections when batch shape + spawn
  time match — hides task-index interleaving and makes hierarchy obvious.
- Sort tree by spawn time, then task_index. Step indicator is one colored
  pill (primary while running, emerald when done) inside the row, not a
  trailing pill that wrapped under the chevron.
- Tree picks up `subagent.start` (not only `spawn_requested`) and prunes
  delegate-tool fallback rows once native subagent events land for the
  session — fixes duplicate "Delegated task" rows alongside the real ones.

* feat(desktop): Esc closes every OverlayView-based overlay

Lift the keyboard handler into the shared OverlayView so Agents, Settings,
Command Center — and anything we build on top of it later — all dismiss on
Esc by default. Nested Radix dialogs stop propagation themselves, so a
modal opened inside an overlay (e.g. model picker inside Settings) still
closes the modal first, not the overlay underneath.

Drop the now-redundant Esc handlers in Settings (kept Cmd/Ctrl+P) and
Command Center.

* fix(desktop): drop numbered step pill on subagent rows

The pill was getting clipped at the overlay edge anyway. Just use the
status glyph (●/✓/✗/■/○) — the delegation header already conveys
"3 workers, 3 active", and order in the list implies which step you're
looking at.

* fix(desktop): drop noisy "returned N items / empty object" stub strings

When a tool returns nothing useful, the row should be silent — the title
("Search Files", etc.) already tells the user what happened. Counting the
fields in an opaque payload is engineer-noise.

`formatToolResultSummary` and `minimalValueSummary` now return '' for
empty arrays / records / unrecognized values; tool-fallback already hides
the detail section when its body is empty.

* refactor(desktop): subagent rows borrow chat tool patterns (fade-in, lucide glyphs, shimmer)

Pull the agents view closer to how chat tool blocks render:
- statusGlyph() returns the same lucide BrailleSpinner / CheckCircle2 /
  AlertCircle vocabulary as tool-fallback's statusGlyph
- Stream lines fade-in via useEnterAnimation (one-shot WAAPI), keyed per
  entry so streamed deltas settle in instead of popping
- Subagent rows fade in too, and pick up the existing data-slot=tool-block
  spacing rules between blocks
- Active stream line trails a BrailleSpinner instead of a hand-rolled
  pulsing rectangle
- Goal text drops FadeText (which forces nowrap); keep FadeText only for
  the single-line meta subtitle
- Running rows shimmer the title — same affordance the chat thinking row
  uses

* refactor(desktop): make /agents subagent-only, drop sidebar + dead sections

Activity rail and History stub were both noise. Strip the split layout,
sidebar, route enum, and the rail/stub helpers — the overlay is now just
the spawn tree, centered in a max-w-3xl column so it stops claiming the
whole screen for one section's worth of content.

* feat: update cron modals

* Add dedicated GUI log stream for dashboard debugging.

Capture dashboard and PTY websocket lifecycle failures in gui.log and expose it via hermes logs.

* Improve desktop runtime UX by surfacing inference readiness in gateway status and hardening WSL link opening.

This also stabilizes markdown code/table block spacing and adds root-install guards so desktop dev runs use a healthy workspace dependency tree.

* Log detailed GUI websocket failure metadata.

Capture richer reject/disconnect/send/parse context for dashboard gateway websocket flows so GUI connection failures are diagnosable from logs.

* Default dashboard startup logging to GUI mode.

Detect the dashboard subcommand during early CLI bootstrap so gui.log is attached from process start and GUI startup failures are always captured.

* Clean up gateway status conditionals and logging bootstrap mode detection.

Simplify nested dashboard gateway status branches for readability and use a concise first-subcommand check when selecting early GUI logging mode.

* add logging to nsis installer

* feat: glass ui pass

* fix(desktop): persist inline assistant errors across hydrate/resume

- Detect provider failure text arriving via message.complete
  (HTTP 4xx, "API call failed after N retries", Provider/Gateway
  error: ...) and persist as an inline assistant error instead of
  regular completion text, blocking the hydrate that was wiping it.
- preserveLocalAssistantErrors: merge by id so same-id hydrated
  messages keep their local error, and preserve the optimistic
  user+error pair as a unit (with tail-user dedupe).
- Hook all hydrate/resume writers (use-session-actions resume +
  fallback, hydrateFromStoredSession, syncSessionStateToView) into
  the merge so stale snapshots can't clobber a failed turn.
- Add error to chatMessagesEquivalent so the resume diff actually
  sees error-only changes and paints them.
- editMessage on a failed turn now submits a plain resend (no
  truncate_before_user_ordinal) and retries plainly on the
  "no longer in session history" race.

Style polish on touched files:
- Inline error: text-only treatment (no card).
- User stop / edit-composer send: shared Tabler IconPlayerStopFilled
  glyph + shared icon-button class slot for parity.

* feat(desktop): theme xterm with active light/dark mode

The right-sidebar terminal hardcoded a light palette, which read poorly
on the dark glass surface. Subscribe to `useTheme().resolvedMode` and
hot-swap `term.options.theme` so Shift+X (and any other mode change)
updates the terminal in place without tearing down the PTY session.

Dark mode uses xterm's built-in defaults (white fg/cursor + vivid ANSI
16) with just a transparent background so the glass shows through;
light mode keeps the existing hand-tuned overrides for legibility on a
bright surface.

* feat(sidebar): right-click + drag-reorder sessions and workspaces

- Wire right-click on session rows to open the same actions menu;
  suppresses the OS-native context menu so Windows stops looking awful.
- Share dropdown + context menu items via useSessionActions() driving
  a single declarative ItemSpec[]; render polymorphic over MenuItem.
- New shadcn ContextMenu primitive mirroring DropdownMenu styling.
- Restore drag-and-drop reordering for Agents (lost during the cwd
  cleanup) and add reordering of workspace groups via a right-side
  grab handle. Pinned reorder unchanged.
- Generic orderByIds<T> replaces the duplicated session/group orderers;
  useSortableBindings() hook collapses the two Sortable wrappers.
- cursor-pointer on every actionable element; cursor-grab on handles.
- KISS pass: baseName() helper, AGE_TICKS table, single WORKSPACE_PAGE
  constant, flatter SidebarSessionsSection render.

* feat(desktop): solarize the xterm palette in both light & dark

xterm's default ANSI 16 is tuned for dark and reads candy-bright on the
light glass surface (vivid cyans/greens). Ship the canonical Solarized
palette (Schoonover) for both modes — same 16 accents either way, only
fg/cursor swap between `base00/01` (light) and `base0/1` (dark), so a
prompt's colors look uniform across a Shift+X toggle.

Background stays transparent in both modes — Solarized's cream/slate
backgrounds would fight the glass.

* feat(desktop): virtualize chat thread + sidebar via TanStack Virtual

Replaces `use-stick-to-bottom` and per-row session rendering with
`@tanstack/react-virtual`, matching what Cursor uses.

Chat thread (`thread-virtualizer.tsx`):
- Natural-flow virtualization (padding spacers, not absolute items) so
  `position: sticky` on the human bubble still resolves cleanly against
  the scroller.
- Custom at-bottom anchor: pins when armed, disarms on user-driven
  upward scroll, re-arms at bottom, jumps on session switch +
  `thread.runStart`.
- Loading indicator and `--thread-last-message-clearance` move to a
  real `[data-slot=aui_composer-clearance]` node; drops the brittle
  `:nth-last-child(1 of …)` rule that can't fire reliably under
  virtualization.

Sidebar (`virtual-session-list.tsx`):
- Flat agents list virtualizes at >=25 rows; pinned and
  workspace-grouped paths stay direct-render.
- `SortableContext` keeps all IDs; only the window mounts; dnd-kit's
  `setNodeRef` is merged with `virtualizer.measureElement` so rows
  participate in both DnD hit-testing and TanStack measurement.

Drops `use-stick-to-bottom`. Streaming test gets a global
`offsetWidth/offsetHeight` stub so the virtualizer's viewport sizing
works in jsdom; the scroll-up-doesn't-pull-back invariant still passes.

* feat: more ui qa

* fix(desktop): trim sidebar terminal startup spacer

Drop zsh's initial spacer row before writing the first terminal prompt so new sidebar terminal sessions do not open with a selectable blank line.

* chore: uptick

* feat(desktop): thin installer + first-launch install.ps1 bootstrap

Converges the Windows packaged desktop installer onto a single canonical
install topology: drop the Electron shell only (~80MB instead of ~500MB),
clone Hermes Agent at a build-time-pinned commit on first launch via
install.ps1's stage protocol, and treat the resulting git checkout at
%LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location
(same path the CLI installer uses).  Future updates flow through the
existing applyUpdates() git-pull path.

Replaces the previous fat-installer architecture where the .exe bundled
a pre-staged hermes-agent source tree under resources/hermes-agent/ that
was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated
factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT
mismatch on path resolve, isGitCheckout guard regressions, pyproject
hash drift detection inside the sync loop).

Architecture overview
---------------------

  Build time
    apps/desktop/scripts/write-build-stamp.cjs writes
    apps/desktop/build/install-stamp.json with {commit, branch, builtAt,
    dirty}.  Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to
    `git rev-parse HEAD` locally.

    apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset
    of @homebridge/node-pty-prebuilt-multiarch from the workspace-root
    node_modules into apps/desktop/build/native-deps/.  Workspace dedup
    hoists this dep to the root, out of reach of electron-builder's
    `files:`-restricted collector; staging gives us a deterministic
    path to extraResources.

    electron-builder ships both into resources/install-stamp.json and
    resources/native-deps/ respectively.

  Boot resolver (electron/main.cjs)
    Resolver order:
      1. HERMES_DESKTOP_HERMES_ROOT override
      2. SOURCE_REPO_ROOT (dev mode)
      3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete
         marker -- the post-install fast path
      4. `hermes` on PATH (CLI-installed user adding the desktop)
      5. pip-installed hermes_cli via system Python
      6. bootstrap-needed sentinel -> hand off to runBootstrap

    Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER /
    syncTreeExcludingVenv machinery (-200 lines).  The isGitCheckout
    guard that bit us in the install.ps1 PR is gone.

  First-launch bootstrap (electron/bootstrap-runner.cjs)
    1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else
       download from GitHub raw at INSTALL_STAMP.commit (cached at
       HERMES_HOME\bootstrap-cache\install-<sha>.ps1).
    2. Fetch the stage manifest via install.ps1 -Manifest -Commit X
       -Branch Y.
    3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json
       -Commit X -Branch Y per stage.
    4. On all stages green: write the .hermes-bootstrap-complete
       marker with {schemaVersion, pinnedCommit, pinnedBranch,
       completedAt, desktopVersion}.

    Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log.  Cancellation
    via AbortSignal.  Manifest cache so retries don't re-download.

  Install overlay (src/components/desktop-install-overlay.tsx)
    Mounted alongside the existing onboarding overlay; flexbox card
    with header (static) + middle (scrollable) + footer (failure-only,
    static).  Subscribes to hermes:bootstrap:event IPC + resyncs from
    hermes:bootstrap:get on mount/reload.  Renders:
      - 14-stage checklist with per-stage state icons
      - Overall progress bar + current-stage spotlight
      - Auto-expanded installer-output panel on failure
      - "Copy output" button (full ring buffer + error to clipboard)
      - "Reload and retry" wired through hermes:bootstrap:reset to
        clear main.cjs's latched failure
    Synthetic empty-manifest event from main.cjs flips the overlay to
    'active' immediately so the slow install.ps1 download doesn't
    leave the user staring at the generic Preparing splash.

  Failure latching (main.cjs)
    bootstrapFailure module-scope variable holds the rejection after
    install.ps1 fails.  startHermes() throws the latched error
    immediately when set, bypassing the entire ensureRuntime +
    runBootstrap chain.  Without this, the renderer's ensureGatewayOpen
    retries would re-run install.ps1 in a 5-10 min hot loop while the
    user was still reading the failure overlay.  Cleared via
    hermes:bootstrap:reset on user-driven retry.

  Unsupported-platform overlay (1F)
    macOS / Linux packaged builds (no install.sh stage protocol yet)
    emit an unsupported-platform event with a copy-pasteable install
    command + docs URL.  Dedicated overlay branch with "Copy command"
    + "I've run it -- retry" buttons.

install.ps1 additions (Phase 1F.3 + 1F.5)
-----------------------------------------

  New -Commit and -Tag string params.  Precedence Commit > Tag >
  Branch.  Honoured by all three code paths (update / fresh clone /
  ZIP fallback), with archive URL selection that handles each
  ref-type variant.  Detached-HEAD checkouts intentionally -- they're
  pins, not branches the user pulls into.

  EAP=Continue wrap around the new pin-step git invocations.  `git
  fetch origin <commit>` writes the routine 'From <url>' info line to
  stderr; under the script's global EAP=Stop that terminates the
  script even though fetch+checkout succeed.  Matches the established
  pattern in Install-Uv, Test-Python, _Run-NpmInstall.

Backend fix (hermes_cli/web_server.py)
--------------------------------------

  CORS allow_origin_regex now accepts Origin: 'null'.  Packaged
  Electron loads index.html via file://; Chromium sets the WebSocket
  upgrade Origin header to the opaque origin 'null', which the old
  regex rejected with HTTP 403 before gateway_ws() ever ran.  This
  failure mode was masked in the older FACTORY_HERMES_ROOT
  architecture because the resolver often found an existing hermes
  on PATH with different binding behavior.

  Security maintained: localhost-only bind keeps cross-machine pages
  out; per-process session token still gates every authenticated
  /api/ endpoint regardless of Origin.

Desktop QoL
-----------

  DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I).
  Field-debugging trade-off: tiny attack surface increase versus
  a much better support story when CSP / WS / theme issues surface.

  NSIS prereq-check page deleted (-767 lines).  The standard
  Welcome -> License -> Directory -> InstallFiles -> Finish wizard
  now installs without custom Python/Git/ripgrep detection -- those
  prereqs are install.ps1's job at first launch.

Test infrastructure (Phase 1G)
------------------------------

  apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform
  bundle validator (was darwin-only and asserted on dead factory-
  payload paths):
    NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard)
    POSITIVE: install-stamp.json carries a real commit + branch
    POSITIVE: node-pty native deps shipped under resources/native-deps
    POSITIVE: renderer dist/index.html reachable (asar or unpacked)
  New nsis mode and npm run test:desktop:nsis script.

Validated end-to-end on clean Win10 VM
--------------------------------------

  Confirmed: NSIS installer drops Electron shell, app launches,
  install overlay shows progress, install.ps1 clones the pinned
  commit, 14 stages run to completion, marker written, backend
  spawns, WebSocket connects, onboarding overlay asks for API key,
  main UI loads, integrated terminal works.

  Failures handled: bootstrap stays failed (no hot-loop retry),
  "Copy output" gives actionable transcript, "Reload and retry"
  explicitly re-runs install.ps1.

What's deferred
---------------

  - MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest
    with runFullTrust, signed and submitted to Microsoft Store.
  - install.sh stage protocol parity (Phase 2): once shipped, the
    unsupported-platform overlay becomes drive-it-yourself and
    macOS/Linux packaged installers gain feature parity with Windows.

* feat(desktop): persistent terminal pane + fullscreen takeover

Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal
tab that takes over the chat pane area without unmounting the shell. The
xterm host is mounted once at the layout root and CSS-overlayed onto
whichever <TerminalSlot /> is currently active, so the PTY session,
scrollback, selection, focus, and WebGL renderer survive every toggle.

Also:
- WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint
  faithfully instead of muting through xterm's default DOM renderer
- File drag/drop from the project tree or OS into xterm — paths are
  shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY
- Solarized dark canvas with brights promoted to real accent variants
  (Schoonover's UI-gray brights washed out every TUI accent)
- Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty
  parents (CI runners, Cursor's agent shell) so the embedded shell gets
  truecolor regardless of how Electron was launched
- rAF-debounced ResizeObserver — running fit.fit() synchronously during
  sibling pane transitions crashed the WebGL texture-atlas rebuild

* fix(install.ps1): strip UTF-8 BOM regression that broke 'irm | iex'

The canonical install flow

    irm https://raw.githubusercontent.com/.../scripts/install.ps1 | iex

fails on PowerShell 5.1 with a cascade of 'The assignment expression
is not valid' errors at every param() default value:

    [string]$Branch = 'main',
                      ~~~~~~
    The assignment expression is not valid. The input to an assignment
    operator must be an object that is able to accept assignments...

Root cause: scripts/install.ps1 carries a UTF-8 BOM (0xEF 0xBB 0xBF)
as its first three bytes. 'irm' returns the response body as a string;
on PS 5.1 the BOM survives into that string as a leading \ufeff
character. 'iex' then evaluates the string and PS's parser chokes
on the invisible character before param() -- error recovery proceeds
into the body but every assignment is reported as broken.

This was the exact failure mode the install.ps1 hardening pass (PR
#27224) deliberately fixed by stripping the BOM and ensuring the
file body is pure ASCII. Commit 4279da4db ('fix(windows): make
PowerShell installer parse in 5.1') re-introduced the BOM later,
unintentionally undoing the irm|iex compatibility fix; the merge
that brought it into bb/gui carried it forward.

Fix: strip the three BOM bytes. File body is verified pure ASCII
(any-byte > 127 returns false), so PS 5.1 with no BOM falls back to
Windows-1252 decoding which is identical to ASCII for our content.
Both install paths now work:
  - 'irm ... | iex' (canonical CLI)
  - 'powershell -File install.ps1' (programmatic / desktop bootstrap)

* install.ps1: detect ARM64 Windows reliably for Node and Git stages

Add a Get-WindowsArch helper that reads Win32_Processor.Architecture
via CIM (invariant to PowerShell host bitness) with PROCESSOR_ARCHITEW6432
fallback. Use it in:

- Install-Git: previously only triggered the arm64 PortableGit asset
  when invoked from a native-ARM64 PowerShell host. WoW64 / emulated
  x64 hosts (the default powershell.exe on Windows-on-ARM) saw
  PROCESSOR_ARCHITECTURE=AMD64 and fell through to the x64 PortableGit
  build, leaving ARM64 users on emulated Git for Windows.

- Test-Node: previously hardcoded the Node download to win-x64 on any
  64-bit OS, so ARM64 users always got x64 Node under Prism emulation
  even though Node ships an arm64 build for Windows. The winget
  fallback now also passes --architecture arm64 on ARM64.

Python remains x86_64 by design: uv intentionally prefers
windows-x86_64 cpython on ARM64 hosts for ecosystem (wheel)
compatibility (see astral-sh/uv#19015).

* install.ps1: harden Install-SystemPackages against winget msstore failures

The previous winget invocation discarded stdout/stderr and trusted no
signal at all -- not the exit code (winget exits 0 even when it bails
"please specify --source"), not output (sent to Out-Null), not the
catch handler (winget returning 0 means no exception fires). The only
trust signal was a post-install Get-Command rg / Get-Command ffmpeg
check, which would also miss the package because %LOCALAPPDATA%\
Microsoft\WinGet\Links (where winget puts command aliases) is added to
PATH by AppExecutionAlias machinery only in fresh shells. End result on
machines where the msstore source has a cert problem (0x8a15005e --
common on Windows-on-ARM and some corporate networks): silent failure,
no log, no breadcrumb, and the user is told the install succeeded.

Specifically:

- Pin --source winget on every winget install call. Defeats the broken-
  msstore-source path. We ship nothing from msstore so this is safe and
  forward-compatible.

- Add --exact --id for a tighter package match.

- Capture each winget invocation's combined stdout/stderr + exit code to
  %TEMP%\hermes-winget-<pkg>-<n>.log instead of Out-Null. On the happy
  path the log is deleted after the post-install check confirms the
  binary is on PATH; on failure the log is kept and its path is named in
  a Write-Warn so the user has something to grep.

- Refresh PATH to include %LOCALAPPDATA%\Microsoft\WinGet\Links in
  addition to the User/Machine env-var hives, so Get-Command sees newly-
  installed winget aliases in the same process.

- No behavior change on the happy path. Same Write-Info/Success/Warn
  cadence, same fallback order (winget -> choco -> scoop -> manual),
  same $script:HasRipgrep / $script:HasFfmpeg outputs.

Verified end-to-end on a real Snapdragon ARM64 Windows host: ripgrep
uninstalled, stage re-run, [OK] ripgrep installed in 1.4s, ok:true.

* desktop: swap node-pty fork for upstream microsoft/node-pty 1.1.0

The previous dependency, @homebridge/node-pty-prebuilt-multiarch@0.13.1,
publishes no win32-arm64 prebuilds on its v0.13.x line, and its v0.14.x
betas (which do add an arm64 Windows build) ship no electron-vXXX-win32-
arm64 prebuilds at all -- so packaged Electron 40 builds (NMV 143) would
fail at runtime even on a successful npm install. Net effect: the
desktop's integrated terminal was unbuildable on Windows-on-ARM, in
both dev (npm install fails: 404 fetching the node-vXXX-win32-arm64
prebuilt) and packaged builds (no Electron-ABI prebuilt exists).

The homebridge fork was originally created because upstream node-pty
shipped no prebuilds at all. That hasn't been true since node-pty@1.0
(April 2024), which:

- bundles prebuilts for mac (arm64+x64) and Windows (arm64+x64) directly
  inside the npm tarball -- no GitHub-Releases fetch, no missing-binary
  failure mode
- uses N-API (node-addon-api) for ABI stability across Node and Electron
  major versions, so the same pty.node binary loads under Node 22 (dev)
  and Electron 40+ (packaged) without per-ABI rebuilds
- is what VS Code, Hyper, and Theia actually ship

API surface is identical (spawn / onData / onExit / write / resize /
kill) -- no call-site changes needed.

Specifically:

- apps/desktop/package.json: replace the @homebridge fork with
  node-pty@1.1.0 (exact pin). Widen `asarUnpack` from `["**/*.node"]`
  to also unpack `**/prebuilds/**`, because node-pty ships runtime-
  execed helpers alongside its .node files (darwin spawn-helper has no
  extension and would not be matched by `**/*.node`; conpty.dll,
  OpenConsole.exe, winpty.dll, winpty-agent.exe on Windows are also
  exec'd at runtime and cannot live inside asar).

- apps/desktop/electron/main.cjs: update both require() strings to
  match the new package name and the new staged path under
  resources/native-deps/node-pty/.

- apps/desktop/scripts/stage-native-deps.cjs: point at node_modules/
  node-pty. node-pty's prebuilts live under prebuilds/<plat>-<arch>/
  (not build/Release/), so update the include glob to copy that dir.
  Per-arch staging keeps the resource bundle small (target arch comes
  from npm_config_arch when electron-builder cross-builds, else
  process.arch). Explicitly enumerate file types in the prebuilds glob
  so the ~25 MB of .pdb debug symbols that prebuild-install bundles
  for Windows crash analysis don't bloat the installer (29 MB -> 2.6 MB
  staged on win32-arm64). Re-assert +x on the darwin spawn-helper
  defensively, since a stripped mode bit would manifest as a silent
  ENOENT at first pty.spawn().

- apps/desktop/scripts/test-desktop.mjs: update expectedNativeDepPaths()
  and its assertion site to look at prebuilds/<plat>-<arch>/ instead of
  build/Release/. Add an explicit spawn-helper-exists check on darwin
  so a regression in the asarUnpack glob would fail loudly in CI rather
  than at first PTY spawn.

Trade-off: Linux end-users lose prebuilts and fall back to building
node-pty from source on `npm install`. Acceptable because Hermes
ships no Linux desktop builds (desktop-release.yml matrix is mac + win
only, package.json declares no `linux` target), and Linux developers
hacking on the desktop already need a C++ toolchain for the rest of
the stack.

Verified on Windows 11 ARM64 (Snapdragon):
  npm install                                          -> exit 0
  node -e "require('node-pty').spawn(...)" round-trip  -> OK
  stage-native-deps                                    -> 27 files, 2.6 MB
  load from staged tree (simulates packaged fallback)  -> ConPTY
                                                           round-trip OK

* desktop+gateway: harden Slack socket recovery and Windows restart dedupe (#28873)

* desktop+gateway: harden Slack socket recovery and Windows restart dedupe

Fix Slack Socket Mode reliability by adding a watchdog/reconnect path so silent socket task drops no longer leave the adapter stuck. Harden Windows gateway lifecycle by avoiding desktop-binary path collisions, making gateway PID scans case/extension tolerant, and reusing in-flight restart actions to prevent duplicate gateway spawns.

* test(slack): add Socket Mode watchdog/reconnect behavioural coverage

Drive the new Slack Socket Mode self-healing logic through a fake AsyncSocketModeHandler so we can simulate the P0 silent-hang failure mode (task exit, transport disconnected, intentional shutdown, concurrent reconnect attempts) without touching real Slack.

* fix(slack,desktop): address Copilot review on watchdog races and path normalization

- connect(): explicitly cancel + await the prior socket watchdog before flipping _running, so an old monitor cannot exit between teardown and respawn (Copilot #1)
- _socket_watchdog_loop: wrap the body in try/except + add a done-callback that respawns on unexpected crash, so a transient bug cannot permanently disable self-healing (Copilot #2)
- normalizeExecutablePathForCompare: use the resolved path for realpathSync so non-string inputs cannot leak through (Copilot #3)
- Add tests for crash-recovery and atomic watchdog replacement across reconnects

* fix(slack): tighten connect() error path and clarify watchdog test intent

Address Copilot review round 2.

- connect(): wrap _start_socket_mode_handler/_ensure_socket_watchdog in a focused try/except so any failure rolls back partially-started handler/task state and leaves _running=False, ensuring the platform lock is always released by the outer finally
- Defer _running=True until after the handler is actually started so the watchdog observes a live socket task immediately and never spins against a half-built adapter
- Rename test_watchdog_self_restarts_after_unexpected_crash to test_watchdog_cancellation_does_not_respawn (matches what it actually asserts) and add test_watchdog_unexpected_exit_respawns_via_done_callback that drives a real RuntimeError through _on_socket_watchdog_done and verifies a fresh task replaces the crashed one

* fix(web_server): serialize action spawn check+store under a threading lock

Address Copilot review round 3.

FastAPI runs sync handlers on its threadpool, so two near-simultaneous /api/gateway/restart (or /api/hermes/update) requests could both observe "no live process" in _spawn_hermes_action's poll-based dedupe and double-spawn. Add a module-level _ACTION_SPAWN_LOCK around the entire check + Popen + _ACTION_PROCS store sequence so the dedupe is atomic across threads.

* fix: address Copilot review round 4

- slack.disconnect(): mirror connect()'s defensive cleanup — catch the broad Exception path on watchdog await so handler shutdown and lock release still run if the watchdog raised before cancellation took effect
- web_server._spawn_hermes_action: wrap subprocess.Popen in try/except so a missing executable / permission error closes the log file handle, writes a failure marker, and re-raises instead of leaking a file descriptor
- gateway._scan_gateway_pids: drop the over-broad "hermes.exe --profile" / "hermes.exe -p" patterns that would match any Hermes CLI subcommand using a profile flag (e.g. `hermes.exe --profile foo dashboard`); rely on the "hermes.exe gateway" + "hermes-gateway.exe" tokens instead
- tests: tighten _fake_create_task to assert coroutine input and return a real asyncio.Task that stays pending until pytest teardown, and update the three callsites whose mocked AsyncSocketModeHandler.start_async returned a non-coroutine value

* fix(slack): reset multi-workspace state on reconnect

Address Copilot review round 5.

connect() is reentrant (gateway restart, in-process reconnect), but it was leaving _bot_user_id / _team_clients / _team_bot_user_ids populated from the previous session. A reconnect that rotated the primary token or dropped a workspace would silently keep the stale bot user id and stale workspace client maps, leading to dispatch against gone workspaces.

Clear these three pieces of state right after _stop_socket_mode_handler() and before the auth_test loop, then let the loop repopulate from the current tokens. Add test_reconnect_refreshes_multi_workspace_state to lock it in.

* nix: package apps/desktop as .#desktop (#28964)

Adds nix/desktop.nix building the Electron renderer with buildNpmPackage
and wrapping nixpkgs' electron binary.  Reuses .#default by setting
HERMES_DESKTOP_HERMES to its hermes binary, so the desktop's resolver
picks up the fully-wired nix hermes (venv, bundled skills/plugins,
runtime PATH) without reimplementing agent resolution.

- nix/desktop.nix: renderer + electron wrapper
- nix/hermes-agent.nix: finalAttrs form, exposes hermesDesktop in passthru
- nix/packages.nix: exposes .#desktop + adds to fix-lockfiles
- apps/desktop/package-lock.json: standalone hermetic lockfile

nix build .#desktop && nix run .#desktop both clean.

* fix(desktop): probe steps 4 & 5 of resolveHermesBackend before trusting

A user-reported failure on Windows-on-ARM: a pre-installed Python 3.13
on PATH makes findSystemPython() succeed, so resolveHermesBackend
returns a backend pointing at it -- but hermes_cli isn't in that
interpreter's site-packages. The spawn dies with ModuleNotFoundError
and the user sees a dead GUI instead of the first-launch installer.

Same shape can hit step 4 (existing `hermes` on PATH) when a stale
shim survives a partial uninstall.

Add cheap exit-code probes -- `python -c "import hermes_cli"` for
step 5, `<hermes> --version` for step 4 -- and fall through to step 6
(bootstrap-needed) on failure. install.ps1 then runs as if on a clean
box and the venv gets built.

Probes live in a standalone electron/backend-probes.cjs module so they
can be unit-tested with node --test, same pattern as bootstrap-platform.cjs
and hardening.cjs. New test file wired into test:desktop:platforms.

* test(desktop): allow `node-pty` bare-require in packaged entrypoints

Pre-existing failure on bb/gui since c858484b4 swapped the node-pty
fork for upstream microsoft/node-pty 1.1.0. main.cjs intentionally
bare-requires node-pty (it's hoisted by workspace dedup in dev, and
staged to resources/native-deps via scripts/stage-native-deps.cjs +
extraResources for packaged builds, with a try/catch fallback at
line ~38). The allowlist hadn't been updated to match -- same shape
as `electron`, which was already allowed.

* chore(deps): refresh root lockfile for dashboard @nous-research/ui 0.14.0

apps/dashboard/package.json was bumped to @nous-research/ui 0.14.0 (+
flag-icons ^7.5.0, motion ^12.38.0) but the root package-lock.json was
never refreshed. Running `npm install` from the repo root now
materialises 0.14.0's transitive closure (launder, bumps for
@nanostores/react, nanostores, sanitize-html, tailwind-merge).

No code changes; purely a lockfile catch-up so fresh checkouts on bb/gui
get a working dashboard install.

* chore(desktop): bump version to 0.0.1

First non-placeholder version so electron-builder's artifactName template
produces `Hermes-0.0.1-win-x64.exe` instead of the obviously-unreleased
`Hermes-0.0.0-...`. No release process yet; this just stops the artifact
filename from telling users "you got a debug build."

Bumped in three slots that all carry the desktop app's version:
- apps/desktop/package.json (source of truth)
- apps/desktop/package-lock.json (per-app lockfile, kept for CI parity)
- root package-lock.json's apps/desktop workspace entry

Identity-of-build for first-launch bootstrap continues to come from
build/install-stamp.json (commit SHA + builtAt), unchanged.

* fix: fs icon color

* perf(desktop): cut per-keystroke layout + listener churn in chat composer

Empirical work via CDP harnesses under apps/desktop/scripts/ (see
profile-typing-lag.md):

  jsListeners growth (per round of 200 chars + GC):
    before: +35  (verified leak — listeners stuck after 1st trigger popover use)
    after:  +0

Four narrow edits in src/app/chat/composer/index.tsx:

1. Drop the per-keystroke `editorRef.current.scrollHeight` read used to
   decide composer expansion. Replace with `draft.length > 60` heuristic;
   the existing ResizeObserver still catches edge cases. `scrollHeight`
   is a forced-layout call and was firing on every char until the first
   wrap.

2. Bucket measured composer height to 8px before writing
   `--composer-measured-height` / `--composer-surface-measured-height`
   on `documentElement`. Without this, the editor grows ~1px per char,
   setProperty fires every keystroke, computed style is invalidated tree-
   wide.

3. Remove the dead `$composerDraft` two-way sync. Nothing outside the
   composer subscribed to that atom (verified via grep). Two useEffects
   on `[draft]` were pushing draft→atom and atom→aui per keystroke for
   no consumer. Also drop the per-keystroke
   `reconcileComposerTerminalSelections` call; it was pruning stale
   labels for `terminalContextBlocksFromDraft`, but that helper already
   ignores labels not in the current submitted text, so pruning per
   keystroke was just bookkeeping.

4. `refreshTrigger` fast-bails when the draft contains neither `@` nor
   `/`. Previously `textBeforeCaret(editor)` ran on every input/keyup
   regardless; `range.toString()` inside is O(n) over draft length.

Synthetic typing latency p50/p90/p99 is similar before vs after on a
freshly-loaded session (Blink can already handle ~30cps typing into a
contentEditable on its own); the real win is the listener leak being
gone and the global computed-style invalidations dropping ~8× when the
composer is sitting at a fixed height row.

The `Enter → stall` follow-up (see profile-typing-lag.md §"Submit /
TTFT stall") is unmeasured here — needs a throwaway session because
the harness fires a real prompt. Not blocking this commit.

* perf(desktop): cut FadeText forced layouts during streaming

The slowest user-felt path is typing into the composer while the
assistant is streaming. Profile (scripts/profile-under-stream.mjs):

  FadeText measureOverflow self time:  35.8 ms → 18.1 ms  (-50%)
  total active CPU during 7s window:   ~150 ms → ~50 ms

Two changes in src/components/ui/fade-text.tsx:

1. Drop the `useEffect([children])` that re-ran `measureOverflow`
   (reads scrollWidth + clientWidth — forced layout) on every parent
   re-render. `useResizeObserver` already fires the same callback on
   mount and whenever the host span's box size changes; that covers
   the only case where overflow state can legitimately change. The
   previous explicit useEffect was a forced-layout flush on every
   parent render, which during streaming meant every token tick.

2. Wrap the component in `memo` with a custom comparator that
   short-circuits the entire render when scalar string `children` and
   the className/fadeWidth/style props are unchanged. The hot path
   was tool-fallback's title chips being re-rendered by parent
   streaming updates even though their text was stable; memo+
   comparator skips that.

Also adds two harness scripts under apps/desktop/scripts/:
  - latency-under-stream.mjs (key→paint latency while a turn streams)
  - profile-under-stream.mjs (CPU profile while a turn streams)

Updates profile-typing-lag.md with the streaming numbers and confirms
the Enter→paint submit path is already fast (≤320ms on the populated
session; the 2s "stall after Enter" the user noticed once was a
one-time cold-start, not reproducible at the UI layer).

I'd guess the felt jank in real use is fast-burst typing during a
long-form streaming reply (code blocks + markdown lists multiply the
per-token render cost). The CPU savings here scale linearly with
token volume.

* chore(desktop): drop diag scratch scripts no longer needed

* docs(desktop): correct leak-typing numbers on a real session

Re-ran the leak harness on a populated session (Phaser thread) for both
unpatched and patched builds. The original 'listener leak' was transient
warm-up cost, not a steady-state leak — both versions show 0 listener
growth/round in steady state.

The load-bearing number is forced layouts per character:
  unpatched (HEAD~2):  7.02 layouts/char
  patched   (HEAD):    2.35 layouts/char  (3× fewer)

The patches reduce per-char forced-layout work to Blink's natural floor.
Document node count and heap are flat in both builds.

* perf(desktop): fix "Enter jumps up" on long threads

User reported: after pressing Enter on a long thread, the view jumps up
— the just-submitted message disappears below the fold. Confirmed via
apps/desktop/scripts/measure-jump.mjs:

  before:  distFromBottom 0 → 49.5px, sticks there permanently
  after:   distFromBottom 0 → ~0 (worst case 4px for one frame)

Root cause in useThreadScrollAnchor (thread-virtualizer.tsx):

1. The sticky-bottom logic disarmed on any scroll event where
   `scrollTop < lastTopRef.current`. That check can't distinguish a
   user scrolling up from a programmatic `pinToBottom` write that
   the browser clamped short of bottom (because content also grew in
   the same frame, so `scrollTop = scrollHeight` lands at
   `scrollHeight - clientHeight` for the OLD scrollHeight, which is
   now below the NEW scrollHeight). Result: sticky-bottom disarmed
   permanently on the user's first submit.

2. There was no synchronous pin tied to React's commit phase. By the
   time the ResizeObserver fired and re-pinned, the user had already
   seen ~50ms of "message below the fold" — visually that reads as the
   view jumping up.

Fix:

- `programmaticScrollPendingRef` counter tracks scroll events we
  expect to be ours (one per `pinToBottom` write). The scroll handler
  skips the disarm check when consuming a pending tick, keeps the
  arm bit true, and re-pins synchronously if the browser clamped us
  short of bottom. A depth cap (8) breaks runaway loops in
  pathological streaming-burst layouts.

- `useLayoutEffect` on `groupCount` increase pins BEFORE the browser
  paints, eliminating the visible ~50ms window between optimistic
  user-message insert and the RO/scroll-event chain firing.

Verified on the long Cloud Shadows thread (7-8 turns, ~11k px tall):
all three repro runs now hold within 0–4 px of bottom across the
post-Enter transition. Submit latency unchanged (paint 77–107 ms),
streaming-typing latency unchanged.

Also adds three debug harnesses:
  - measure-jump.mjs   — sample thread scroll across Enter
  - probe-thread.mjs   — dump current thread / scroll state
  - diag-jump.mjs      — intercept scrollTop + RO + mutations across Enter

* perf(desktop): rate-limit thread auto-pin during streaming

Follow-up to the Enter-jump fix. The first version did a synchronous
re-pin loop inside the on-scroll handler when the browser clamped our
`scrollTop = scrollHeight` write short of the new bottom; that gave a
tight 4 px visible jump on Enter, but during streaming the
ResizeObserver fires many times per second as content grows, and each
RO callback re-entered the pin loop. CPU profile showed
`Virtualizer.getMaxScrollOffset` climbing to 22 ms self over a typing-
during-streaming window — the sync re-pin path was paying tanstack-
virtual's recompute cost ~3× per token.

Re-architect:

- RO callback coalesces to one pin per animation frame. Streaming-rate
  RO bursts now cost the same as a single per-frame pin.
- The on-scroll programmatic-counter guard remains (it's what prevents
  the false-disarm bug when the browser clamps a write). It no longer
  does sync re-pins; the next RO/rAF will catch up.
- The useLayoutEffect on groupCount (the path that fires on user
  submit / new turn arrival) ALSO schedules one rAF pin in addition to
  the synchronous pin. This catches the case where React mounts the
  new message in a second commit (after our layout effect ran), which
  grows scrollHeight again. Two pins instead of a tight loop, paid only
  once per turn change.

Net effect on the Cloud Shadows long thread:

  enter-jump transient:   12–20 px for 1 frame (was 49 px permanent)
  CPU during stream+type: `getMaxScrollOffset` dropped out of top-5
                          self-time list
  typing-during-stream:   p50 ~10 ms paint, p99 ~20 ms (1 frame),
                          occasional 40 ms+ outliers during burst
                          token arrivals

Also adds scripts/profile-long-stream.mjs: 20-second streaming profile
with per-500ms FPS histogram + content-length tracking, so we can see
whether streaming render cost grows with message length (it doesn't —
sustained 60 fps).

* perf(desktop): use textContent for trigger precondition

Replace composerPlainText() call inside refreshTrigger's no-trigger
fast-bail with a textContent check. textContent is a browser-native
flat traversal; composerPlainText walks recursively with chip-aware
logic. We only need to know if @ or / appears; either way the trigger
char will be in textContent because chips contain @ in their refText.

Profile shows composerPlainText was ~18ms self over a 12s typing-during-
stream window, called from refreshTrigger on every keystroke. Most of
that was the precondition check (the trigger detection path is the
slow path but only runs when a trigger char is present).

* Revert "perf(desktop): use textContent for trigger precondition"

This reverts commit a6a78ff08a31129a3a47fa55aca260d93af913a5.

* Revert "perf(desktop): cut FadeText forced layouts during streaming"

This reverts commit 88e7d7537cdab87200405edf298e38cb37e0a950.

* Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer"

This reverts commit bff1b3261d18a2427ac6c345c99f8312728346dd.

* Revert "Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer""

This reverts commit b7b378e3a43f94b9f4a1a34155707c6301c0fd87.

* Revert "Revert "perf(desktop): use textContent for trigger precondition""

This reverts commit 0739588f4896902f7f0d4ded8b5eaeb92bfdf042.

* chore(desktop): synthetic-stream perf harness + scripts

Drops the React `<Profiler>` approach (no-op because Vite is currently
serving the production React build) in favor of an externally-observable
measurement stack: rAF frame intervals, `PerformanceObserver({entryTypes:
['longtask']})`, and a `MutationObserver` on the live streaming message.

Adds a synthetic stream driver — `window.__PERF_DRIVE__.stream({...})` —
that pushes tokens through the live `$messages` atom at a controlled rate,
so the assistant-ui runtime, incremental repository, and Streamdown
markdown pipeline see the same workload they'd see during a real LLM
stream, without the LLM cost.

The driver lives in `src/app/chat/perf-probe.tsx`; `main.tsx` side-imports
it under `import.meta.env.MODE !== 'production'` so it tree-shakes out of
prod builds. (Using `MODE` rather than `DEV` because our Vite setup
currently reports `DEV=false` even under `vite dev` — see the dev-build
note in `profile-typing-lag.md`.)

Scripts:
  - measure-synthetic-stream.mjs  drive synthetic + record frame/longtask/mutation
  - profile-synth-stream.mjs      CPU profile + top self-time during synthetic
  - measure-real-stream.mjs       same harness, real LLM stream
  - profile-real-stream.mjs       CPU profile bracketing the real stream window
  - eval.mjs / reload.mjs         small CDP helpers

A real-LLM measurement on Cloud Shadows (gpt-4o-mini, 39 s window) showed
12 longtasks in the same 75-127 ms range the synthetic predicted, so the
synthetic is a faithful proxy.

* perf(desktop): memo FadeText so it skips re-renders when text unchanged

FadeText is used 110+ times inside `tool-fallback.tsx` on a tool-heavy
thread. During streaming each parent re-render previously triggered the
component's `useEffect([children])`, which forced a `scrollWidth` layout
read even when the title text was unchanged. The `useResizeObserver` was
already covering the genuine resize case, so that effect was strictly
redundant work.

Drops the effect and wraps the component in `React.memo` with a custom
comparator that field-compares `className`, `fadeWidth`, and `style`,
plus identity-compares `children` (scalar fast-path; correct for JSX
nodes too since a new node should force a re-render).

Verified via temporary render counter on the 34 MB
`session_20260514_215353_fe0ac8` thread (110 FadeText instances): a
2 s synthetic stream went from ~11k FadeText render calls to 122 —
roughly one render per truly-new instance instead of one per parent
commit per instance.

Doesn't move the longtask needle on its own (Streamdown's markdown
re-parse dwarfs it) but eliminates a steady CPU floor and a class of
forced layouts during streaming. Profile-typing-lag.md documents the
full investigation, including the remaining Streamdown cost as the
real source of the perceived "5 fps moment" hitches.

* perf(desktop): memoize MarkdownText plugins to stop churning Streamdown

The inline `plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}`
on `<StreamdownTextPrimitive>` constructed a new object literal on every
parent render. That broke `<Streamdown>`'s outer memo and forced its
internal `rehypePlugins` / `remarkPlugins` array useMemos to rebuild,
which propagates a new identity into every `<Block>` and defeats Block's
memoization for stable historical blocks.

After memoizing on `[isStreaming]` (the only real dimension of variance),
CPU profile during a 5 s synthetic stream on the 34 MB session shows
`parser` self-time dropping out of the top 10, `compile` cut roughly in
half, and `bn$1` / `m$1` (micromark internals) leaving the top entries.

Doesn't move the visible longtask count on its own — Streamdown's
per-Block parse cost still dominates whenever the last block's content
changes — but it removes a class of unnecessary re-parses for historical
blocks during streaming. See `scripts/profile-typing-lag.md` for the
full investigation.

* perf(desktop): floor assistant-text flush gap to 33ms for predictable batching

`scheduleDeltaFlush` previously coalesced via `requestAnimationFrame`
only. The "at most one flush per frame" guarantee that gives you is fine
for fast streams (>~80 tok/sec) where multiple tokens arrive within a
single frame, but breaks down at typical LLM token rates (30-80 tok/sec)
where each token arrives slower than the rAF cadence and triggers its
own React commit + Streamdown markdown re-parse.

Track `lastFlushAt` and require at least 33 ms between two flushes.
React 18+ auto-batching probabilistically already collapsed some of
these, but the floor makes it deterministic.

A/B on the 34 MB session, 300 tokens at 50 tok/sec (markdown chunks):

| | avgFps | p99 frame | LTs / 5 s | max LT |
|---|---|---|---|---|
| no floor (current rAF) | 54.0 | 38 ms | 2.0 | 145 ms |
| 33 ms floor (this PR) | 54.3 | 41 ms | 1.7 | 110 ms |

`inter-mutation` p50 also tightens from 22-28 ms to a clean 33 ms,
which is the expected signature of a deterministic floor. Doesn't fully
solve the user's perceived hitches — Streamdown's per-Block parse cost
when the last block grows past ~2 k chars is still the elephant — but
it consistently shaves the worst-case longtask and makes the streaming
cadence visibly steadier.

Also threads a matching `flushMinMs` option through the synthetic
stream driver in `perf-probe.tsx` + `scripts/measure-synthetic-stream.mjs`
so the harness can A/B both regimes without spending LLM credits.

See `scripts/profile-typing-lag.md` for the full investigation.

* perf(desktop): useDeferredValue for streaming markdown so parses don't block input

Streamdown's per-Block parse cost grows with the live tail's length and
is unavoidable inside the block-memo pattern (industry standard, see
findings doc). The fix is to stop having that work block the main thread.

`<DeferStreamingText>` is a 12-line wrapper that reads message-part state
via `useMessagePartText`, runs it through `useDeferredValue`, and
re-publishes via assistant-ui's `<TextMessagePartProvider>`. The inner
`<StreamdownTextPrimitive>` reads the deferred value through the normal
`useMessagePartText` hook — no fork, no internal-path imports, fully on
assistant-ui's public API. React's concurrent scheduler then:

  - abandons in-flight deferred renders when a newer token arrives, so
    intermediate states get skipped under fast streams
  - deprioritises the markdown render when the main thread has urgent
    work (typing, scroll), so input stays responsive even while a
    100ms parse is queued

Streamdown already uses `useTransition` for its block-array setState;
this lifts the deferral up to the consumer boundary so it covers the
whole pipeline (preprocess → split → repair → parse → render).

A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
(four trials each, with the 33ms flush throttle on for both):

| | avgFps | p99 frame | LTs/5s | max LT | typing-while-stream p95 |
|---|---|---|---|---|---|
| pre  | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms |
| post | 58.5 | 31 ms | 2.0 | 117 ms | 14-18 ms |

Longtask count + max LT unchanged — useDeferredValue doesn't reduce
CPU, only its priority. The avgFps lift and p99 frame drop are the
proof that the existing CPU is no longer blocking 60 fps cadence. One
clean run logged MUTATIONS=0 — React skipped every intermediate text
state and only committed the final one (textbook deferred-value
behaviour).

The actually-reduce-CPU path is replacing the parser with a state
machine like Flowdown — left for a future PR; see
`apps/desktop/scripts/profile-typing-lag.md` for the full investigation.

* feat(desktop): add hermes gui launcher

* feat(desktop): launch packaged gui builds by default

* bump gui version to 0.0.2

* fix(dashboard): allow file:// origin on loopback WS + diagnostic logging

Upstream commit 2e66eefbc ("fix(dashboard): validate WebSocket Host
and Origin") added a WebSocket Host/Origin guard to block DNS
rebinding against the dashboard.  The guard rejects any Origin whose
scheme is not http/https or whose netloc is empty — which includes
Electron's renderer Origin: file:// when the desktop app loads its
bundle from disk in production mode.

That makes the bb/gui Electron desktop unable to open the gateway
WebSocket against the embedded backend on Windows / macOS prod
builds.  The renderer reports "Desktop boot failed" and the backend
logs:

  WARNING hermes_cli.web_server: gateway-ws reject
      peer=127.0.0.1:NNNN reason=non_loopback_or_bad_origin
      bound_host=127.0.0.1 close_code=4403

DNS-rebinding requires a DNS-resolvable hostname; file:// has no
host component and therefore cannot be the attack vector this guard
exists to block.  When bound to a loopback interface (127.0.0.1 /
::1 / localhost), accept file:// origins so desktop wrappers can
attach.  Non-loopback binds (operator opted into network exposure)
keep rejecting file:// — the loose policy doesn't apply.

Also adds per-reason diagnostic logging in
_ws_host_origin_is_allowed, so future ws-guard rejections name the
specific clause that fired (bad_host / bad_origin_scheme /
origin_host_mismatch) instead of the opaque
"non_loopback_or_bad_origin" surfaced at the call site.

Verified against tests/hermes_cli/test_web_server_host_header.py
(all 11 upstream tests still pass) and hand-tested by opening the
bb/gui Electron desktop dev build against the patched backend.

* fix(tui_gateway): restore _content_display_text helper

Bb/gui had dropped the helper but the orchestrator code merged from main
still calls it (_inflight_text, _message_preview). Re-add the definition
verbatim from main so session.create / _start_inflight_turn don't crash
with NameError on first prompt submit.

* fix(tui-gateway): restore _content_display_text helper lost in main merge

The May 27 merge of origin/main into bb/gui re-introduced two callers of
_content_display_text (in _inflight_text and _history_to_messages) but
dropped the helper definition itself, leaving an unresolved reference.

NameError fires on every user message via _start_inflight_turn ->
_inflight_text, taking down both the TUI and the desktop (which share
this gateway backend) the moment input is dispatched.

Restores the helper verbatim from main (commit 36c99af37) -- pure
structured-content text extractor, no other dependencies.

* fix(telegram): import Set for _dm_topic_chat_ids annotation

self._dm_topic_chat_ids: Set[str] = {...} at line 460 references Set
but only Dict, List, Optional, Any are imported from typing. The file
has no 'from __future__ import annotations', so the annotation is
evaluated at runtime and raises NameError on TelegramAdapter
construction.

* fix(setup): drop shadowing inner importlib.util re-imports

_print_setup_summary and _setup_tts_provider each had 'import
importlib.util' inside a try: block nested deeper in the function
body. Python flips importlib to function-local for the whole scope,
so earlier references in the same function (the neutts branches at
lines 493 / 1109) hit UnboundLocalError before the late import can
run.

The top-of-module 'import importlib.util' at line 14 already covers
both call sites, so dropping the redundant inner imports restores
the intended behavior.

* feat(install.ps1): add -IncludeDesktop switch + Stage-Desktop

The new Hermes-Setup.exe (Tauri bootstrap installer) passes -IncludeDesktop
so users who install via the GUI end up with a launchable Hermes.exe at
apps/desktop/release/<os>-unpacked/. Existing flows are unchanged:

  * The 'irm install.ps1 | iex' CLI one-liner omits the flag — terminal
    users don't need a prebuilt desktop binary; 'hermes desktop' builds
    on demand.
  * The Electron desktop's bootstrap-runner.cjs also omits the flag —
    rebuilding apps/desktop from inside a running Hermes.exe would try
    to overwrite the live binary on disk and fail.

Stage-Desktop runs after Stage-NodeDeps so workspace npm is already
installed when electron-builder fires. It does:
  1. 'npm install' at repo root so apps/* workspaces resolve their deps
     (Electron itself arrives via npm here, ~150MB)
  2. 'npm run pack' in apps/desktop (tsc + vite + electron-builder --dir)
  3. Probes apps/desktop/release/{win-unpacked,win-arm64-unpacked}/Hermes.exe

The --dir mode produces an unpacked launchable binary without an NSIS/MSI
installer artifact — we don't need one because Hermes-Setup.exe spawns the
unpacked binary directly via launch_hermes_desktop.

* feat(installer): Tauri bootstrap installer for first-time onboarding

Hermes-Setup.exe is a small signed Rust+Tauri binary that drives
scripts/install.ps1 stage-by-stage with a native UI matching the
desktop's design language. Replaces the chicken-and-egg pattern of
shipping a 200MB Electron app whose first launch existed only to
run install.ps1.

The architecture:

  Rust backend (src-tauri/):
    bootstrap.rs        orchestrator -- Tauri commands, stage iteration
    install_script.rs   resolve install.ps1 (dev checkout, cache, GitHub raw)
    powershell.rs       spawn powershell, line-stream stdout/stderr, parse JSON
    events.rs           BootstrapEvent types -- mirror bootstrap-runner.cjs
    paths.rs            HERMES_HOME resolution + tracing log setup
    build.rs            bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from
                        'git rev-parse HEAD' at compile time

  React frontend (src/):
    Tauri webview rendering 4 screens (welcome / progress / success /
    failure), driven by nanostores subscribing to the Rust event stream.
    Visual layer reuses the desktop's styles.css wholesale via @import
    so the installer and desktop never drift visually.

  Distribution:
    targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The
    raw target/release/Hermes-Setup.exe IS the artifact on Windows;
    .dmg + .app on macOS; AppImage on Linux. One file, double-click,
    no installer-installing-an-installer pattern.

  Compile-time pinning:
    build.rs reads 'git rev-parse HEAD' and emits
    cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>.
    bootstrap.rs's option_env!() picks these up so the binary fetches
    install.ps1 from the exact SHA it was tested against. CI / release
    builds can override via HERMES_BUILD_PIN_COMMIT env var.

  Windows manifest:
    hermes-setup.manifest declares level='asInvoker' so the
    productName 'Hermes Setup' doesn't trip Windows's installer-
    detection heuristic and refuse to launch without elevation.
    Also declares PerMonitorV2 DPI + UTF-8 active code page + Common
    Controls v6.

Limitations of this initial version:

  * No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe
    ('More info -> Run anyway'). The downstream binaries it produces
    (Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and
    therefore don't carry MOTW, so they launch without SmartScreen
    intervention. Cert procurement tracked separately.

  * macOS and Linux build paths defined but untested -- Windows-only V1.

* fix(installer): pass -IncludeDesktop to manifest, surface launch errors, alias hermes desktop

Three bugs found in the first VM end-to-end test:

1. install.ps1 -Manifest was called WITHOUT -IncludeDesktop, so the
   manifest came back with the 14-stage list (no desktop stage), the
   UI showed '14 steps' and Stage-Desktop never ran. Pass the flag to
   both the manifest fetch and the per-stage runs — install.ps1 gates
   the desktop stage's inclusion on the flag.

2. The Success screen's Launch button silently swallowed the Tauri
   error when no Hermes.exe existed (e.g. Stage-Desktop was skipped).
   Wire the error through to inline UI with an alert callout, so the
   user gets actionable text ('Hermes.exe missing, run hermes desktop
   from a terminal') instead of an unresponsive button.

3. The Success screen tells users to run 'hermes desktop' from a
   terminal but the CLI only accepted 'hermes gui' — invalid choice
   for 'desktop'. Rename the subcommand canonically to 'desktop' with
   'gui' as a backwards-compatible alias. Update the _SUBCOMMANDS sets
   used by session-flag arg parsing + logging-mode probe so both names
   route to the same logic.

* fix(install.ps1): pre-warm electron-builder winCodeSign cache + fix Stage-Desktop $HasNode false-skip

Two bugs caught in the second VM end-to-end run:

1. electron-builder's winCodeSign extraction fails on grandma-class
   Windows boxes because the .7z archive contains macOS symlinks
   (darwin/10.12/lib/libcrypto.dylib and libssl.dylib pointing at
   versioned siblings). Creating symlinks on Windows requires
   SeCreateSymbolicLinkPrivilege, a per-user right that non-admin
   accounts don't have on stock Windows. Result: every fresh install
   on a non-admin user fails Stage-Desktop with a 7-Zip 'cannot create
   symbolic link' error, retried four times, then bails.

   Fix: Initialize-ElectronBuilderCache pre-extracts winCodeSign-2.6.0.7z
   ourselves with -snl (don't preserve symlinks, store as resolved file
   content) AND -x!darwin (skip the entire macOS subtree — irrelevant
   on Windows). Writes to electron-builder's expected cache dir before
   electron-builder gets a chance to try its own broken extraction.
   Idempotent — fast-paths via signtool.exe sentinel check.

2. Install-Desktop's first guard was 'if (-not $HasNode) skip'.
   $HasNode is set by Stage-Node into $script:HasNode, but in
   cross-process driver mode (each -Stage NAME is a fresh powershell.exe
   spawned by Hermes-Setup.exe), that script-scope variable from the
   PREVIOUS process is invisible — so the guard always fired and
   Install-Desktop returned in 900ms with a misleading
   'Node.js not available' reason. The real npm probe below it never
   got to run. Fix: re-probe npm directly via Get-Command when $HasNode
   is empty/false, since by that point Stage-Node has already verified
   Node is installed and the only question is whether *this* process
   can see it on PATH (it can — installer-wide PATH update from Stage-Node).

* fix(install.ps1): tell electron-builder we're NOT signing instead of pre-extracting winCodeSign

The previous commit (c7e46f9f3) worked around the winCodeSign-symlinks-
on-Windows extraction crash by pre-extracting the archive ourselves with
-snl + -x!darwin. That fix was correct but addressed the wrong layer.

The deeper question: why was electron-builder fetching winCodeSign at all
when we have no signing cert configured? Answer: electron-builder
unconditionally pre-warms the toolchain assuming any build MIGHT sign.
The cert auto-discovery never finds anything (we never set CSC_LINK
or anything else), so the signing never happens — but the 100MB fetch
of winCodeSign and its broken-on-Windows symlink extraction does.

Set CSC_IDENTITY_AUTO_DISCOVERY=false (with WIN_CSC_LINK and
WIN_CSC_KEY_PASSWORD also explicitly cleared as belt-and-suspenders)
before invoking npm run pack, and electron-builder skips the entire
winCodeSign apparatus. No download, no extraction, no privilege check.
Env vars are saved/restored around the invocation so we don't leak
the override into Stage-PlatformSdks etc.

Net: removes the 100-line Initialize-ElectronBuilderCache helper that
manually downloaded + extracted winCodeSign-2.6.0.7z. Replaced with
3 env-var assignments. The produced Hermes.exe is functionally
identical — just no longer carries a code-signing-machinery dependency
we never used.

* fix(installer): bump bootstrap-installer.log to capture stage transitions + every install.ps1 line

Diagnosing the second VM failure was impossible because bootstrap-installer.log
contained only the 'starting' banner. Two causes:

1. emit_log() inside run_bootstrap() was tracing::debug! — dropped on the
   floor under the default INFO env-filter.

2. The per-stage sink callbacks (on_stdout_line / on_stderr_line) only
   emitted Tauri events to the frontend; they never tee'd to the log file
   at all. When the failure route mounts, the Tauri event stream is the
   only place the script output lived, and it gets discarded.

3. The Failed / Stage / Manifest / Complete lifecycle frames in emit_event()
   were also Tauri-only — so even the 'which stage failed' frame never
   reached the log.

Fixes:
  * emit_log() → tracing::info!
  * Sink callbacks tee stdout to info!, stderr to warn!, with stage label
    as a structured field for grep'ability
  * emit_event() now matches on the variant and logs each lifecycle frame
    at the right level: Failed → tracing::error!, others → info!

Result: a failing install leaves a complete forensic trail in
bootstrap-installer.log — manifest stage list, every install.ps1
stdout/stderr line tagged by stage, the stage transitions, and the
final error. Same path as before so nothing the user does changes.

* fix(install.ps1): Stage-NodeDeps cross-process $HasNode + stream npm install output to bootstrap log

VM run 3 diagnosis: node-deps stage skipped on the VM (logged
'Skipping Node.js dependencies (Node not installed)') and then
desktop's npm install failed with exit 1 and zero diagnostic detail.

Two root causes:

1. $HasNode false-skip in Stage-NodeDeps — same cross-process bug
   pattern we fixed for Stage-Desktop in c7e46f9f3. Stage-Node ran
   in process A and set $script:HasNode = $true, then exited. Stage-
   NodeDeps ran in fresh process B (Hermes-Setup.exe -Stage NAME
   spawns each stage independently), where that variable doesn't
   exist. Re-probe via Get-Command npm instead of trusting the
   stale script-scope global. The previous stage already verified
   Node so the re-probe succeeds.

2. npm install --silent + Tee to TEMP file hid the real error.
   When the workspace install failed on the VM, the actual reason
   was buffered in $env:TEMP\hermes-npm-desktop-install-*.log and
   the user saw only 'exit 1'. Drop --silent so npm streams its
   full output, drop the TEMP-file dance — the Tauri installer's
   streaming sink already tees every stdout/stderr line to the
   rolling bootstrap-installer.log, so a side log file is dead
   weight that hides the very error we need.

After this, the bootstrap log on a failure will contain npm's full
output (deprecation warnings, ETARGET, native-module compile errors,
whatever) tagged with stage=desktop, making the actual cause
diagnosable instead of an opaque exit code.

* fix(install.ps1): restore Initialize-ElectronBuilderCache (CSC env vars alone aren't enough)

VM run 4 diagnosis: even with CSC_IDENTITY_AUTO_DISCOVERY=false set,
electron-builder still fetches winCodeSign and signs bundled binaries.
The log shows the signing happens BEFORE the cache extraction:

  • signing with signtool.exe  ...\winpty-agent.exe
  • signing with signtool.exe  ...\OpenConsole.exe
  • downloading winCodeSign-2.6.0.7z
  • <symlink privilege error>

Cause: node-pty's bundled prebuilds are listed in apps/desktop's
asarUnpack ['**/*.node', '**/prebuilds/**']. electron-builder
re-signs anything unpacked from asar, regardless of whether OUR
binary gets signed. The signtool invocation needs winCodeSign on
disk, which needs the .7z extracted, which hits the macOS-symlink
crash on non-admin Windows.

The CSC env vars I added in d5fe46727 only kill IDENTITY DISCOVERY
(so OUR Hermes.exe stays unsigned, which is fine — we have no cert).
They don't prevent the toolchain fetch for the bundled-prebuild
re-sign. I removed the pre-extract in d5fe46727 thinking the env
vars subsumed it; that was wrong. Both are needed.

Restoring Initialize-ElectronBuilderCache verbatim from c7e46f9f3
and keeping the CSC env vars. Wrote a clearer doc-comment at the
call site explaining the two-knob interaction so future maintainers
don't drop one half again.

* fix(desktop): disable signtool via signtoolOptions.sign=null, drop dead winCodeSign pre-extract

VM run 5 diagnosis: the pre-extract from 3b29e65c1 ran (extracted 83
files, 24MB) but produced ZERO files at the expected sentinel path
'/winCodeSign-2.6.0/windows-10/x64/signtool.exe'.

Cause: the .7z archive's root entries are 'windows-10/', 'darwin/',
'linux/', etc. — not 'winCodeSign-2.6.0/<arch>'. Extracting with
'-o$cacheRoot' put files at $cacheRoot/windows-10/..., NOT at
$cacheRoot/winCodeSign-2.6.0/windows-10/.... I had the directory
nesting wrong from the start.

And then we observed: electron-builder downloads winCodeSign-2.6.0.7z
under a random numeric filename ('384387955.7z') regardless of what's
already extracted in the parent dir. The cache key isn't the dirname;
it's content-addressed. So the pre-extract approach was doomed even
if the path nesting had been right.

Actual fix: signtoolOptions.sign=null in apps/desktop/package.json's
win build config. electron-builder honors this and skips the bundled-
prebuild signing entirely — no signtool invocation, no winCodeSign
fetch, no symlink-privilege crash. The previous failures all stemmed
from electron-builder pre-signing node-pty's bundled .exes
(winpty-agent.exe, OpenConsole.exe) which are already author-signed
upstream; re-signing with our nonexistent cert was overwriting good
sigs with nothing useful anyway.

Cost: when we DO get a real cert later, we'll add it back with the
sign function pointing at the cert chain. Until then, all-null is
the correct config and unblocks every non-admin Windows user.

Removed Initialize-ElectronBuilderCache (the dead pre-extract).
Removed the call site. Kept the CSC_IDENTITY_AUTO_DISCOVERY env
vars as belt-and-suspenders against a future electron-builder
change that might revive cert auto-discovery.

* fix(desktop): use no-op sign function instead of sign=null

VM run 6 still hit the symlink crash even with signtoolOptions.sign=null.
electron-builder 26.8.1 treats null as 'use the default signtool path'
rather than 'skip signing', so the winCodeSign fetch + extraction still
fired for the bundled prebuild re-sign.

The Electron docs (electronjs.org/docs/latest/tutorial/code-signing)
make it clear signing is OPTIONAL and unsigned apps work fine — users
just see SmartScreen on first launch. The electron-builder mechanism
for 'don't actually sign anything' is to supply a custom sign function
(via signtoolOptions.sign: '<path-to-cjs-module>') that resolves
without invoking signtool.

build-noop-sign.cjs is that module — a 5-line async function that
returns undefined. electron-builder calls it for every binary it would
have signed, gets back a resolved promise, and considers each binary
'signed.' No signtool spawn, no winCodeSign fetch, no symlink crash.

When Nous's cert arrives, replace this file with a real signing hook
(@electron/windows-sign-based or a direct signtool invocation). The
architecture's signing-ready and the cutover is a one-file edit.

* fix(desktop): signAndEditExecutable=false to skip signtool path entirely

After reading app-builder-lib/winPackager.js line 216 + 231 directly:
signAndEditExecutable is the ACTUAL hardcoded gate that short-circuits
both signApp() (which signs Hermes.exe + every shouldSignFile match
including bundled prebuilds) AND createTransformerForExtraFiles().
None of signtoolOptions.sign / sign:null / sign:<custom-fn> gate the
winCodeSign download — that happens before they're consulted.

What we lose: rcedit also runs through signAndEditResources, so
disabling this drops PE metadata (file properties showing 'Hermes' /
'Nous Research' / file description). Cost is real but bounded:
  * Hermes.exe filename, icon, asar contents, app identity intact
  * Task Manager shows 'Hermes.exe' (the filename) not 'Hermes' (PE
    description) — minor downgrade
  * Start menu, taskbar, window title all work normally
  * SmartScreen will warn once (unsigned, same as before)

When the cert lands, flip signAndEditExecutable back to default true,
both signing AND rcedit return, PE metadata is restored.

Removes the no-op sign function (build-noop-sign.cjs) since
signAndEditExecutable=false prevents signtool from being invoked at
all — the custom hook never gets called either.

* feat(install.ps1): write .hermes-bootstrap-complete marker at end of install

The desktop app's main.cjs resolver ladder has a 'bootstrap-needed' rung
that fires when .hermes-bootstrap-complete is missing from
ACTIVE_HERMES_ROOT. Pre-Hermes-Setup, this marker was written by the
packaged-desktop's own bootstrap-runner.cjs at the end of its install
flow. Now that Hermes-Setup.exe runs install.ps1 directly, install.ps1
needs to own the marker — otherwise the desktop sees no marker on first
launch and triggers its legacy first-launch bootstrap (re-running
install.ps1 from inside Electron, the exact recursion Hermes-Setup.exe
was supposed to obviate).

Implementation:
  * New Stage-BootstrapMarker (worker) → Write-BootstrapMarker (helper)
  * Slotted in the manifest right after platform-sdks, before the
    interactive configure/gateway stages, so it runs unconditionally
    when the install reaches the finalize phase
  * Schema mirrors apps/desktop/electron/main.cjs writeBootstrapMarker /
    isBootstrapComplete EXACTLY: {schemaVersion: 1, pinnedCommit,
    pinnedBranch, completedAt}. Schema version stays at 1 so old
    desktops that read marker files written by future install.ps1s
    can still parse them.
  * pinnedCommit comes from -Commit flag (Hermes-Setup.exe passes it)
    or falls back to 'git rev-parse HEAD' in InstallDir
  * pinnedBranch from -Branch flag, defaults to 'main' matching
    install.ps1's own param default

Two PS-5.1 gotchas baked into comments:
  * The ?. null-conditional operator doesn't exist pre-PS7; use
    explicit if-checks on Get-Command results
  * Set-Content -Encoding UTF8 emits a BOM in 5.1 and Node's plain
    JSON.parse rejects BOM — write via .NET's UTF8Encoding(false)
    to produce BOM-less JSON the desktop's readJson() can parse

* feat(installer): drive in-app updates through the Tauri installer

Converge update on the same principle as bootstrap: one driver owns all
repo mutation. The desktop becomes a pure consumer that hands off to
Hermes-Setup.exe --update instead of re-implementing git/pip in Electron.

- hermes desktop --build-only: build without launching, so the installer
  owns the post-update launch (CLI keeps build logic single-sourced).
- Installer AppMode {Install,Update} from argv; get_mode exposed to the UI.
- Installer self-copies to HERMES_HOME/hermes-setup.exe on install success
  (no-op guard during --update re-invocation to avoid the locked-exe copy).
- Installer --update flow (update.rs): wait for the desktop to release the
  venv shim, run 'hermes update --yes --gateway' (branch on exit 0/2/other),
  then 'hermes desktop --build-only', then launch the rebuilt desktop. Reuses
  the bootstrap event channel + progress UI via a synthetic two-stage manifest.
- Desktop applyUpdates() gutted (~105 lines of git/stash/pull/pyproject/pip
  removed) -> thin handoff: spawn updater, app.quit() to free the shim.
  Detection (checkUpdates, commit changelog, behind-count) kept intact.
- install.ps1 creates Start Menu + Desktop shortcuts to the packed Hermes.exe
  (never bare 'hermes desktop', which would rebuild every launch).

* test update

* fix(installer): pass --branch to hermes update in the --update flow

The install is a detached-HEAD checkout of a pinned commit. Without
--branch, 'hermes update' fell back to its default (main) and switched
the checkout to main — a divergent branch that lacks the desktop CLI
command — so the update targeted the wrong branch and the rebuild stage
failed with 'invalid choice: desktop'.

Thread BUILD_PIN_BRANCH (the branch this installer was built against,
and the same branch the desktop detected the update on) into
'hermes update --branch <b>' so update + rebuild stay on-branch.

* test update

* fix(installer): stamp Hermes icon onto Hermes.exe via rcedit (no winCodeSign)

The unpacked Hermes.exe showed the stock Electron icon + name in the
taskbar because build.win.signAndEditExecutable=false disables BOTH
electron-builder's signing AND its rcedit metadata/icon stamping. That
flag is load-bearing: enabling it re-triggers signtool -> winCodeSign,
whose macOS symlinks crash 7-Zip on non-admin Windows (unfixable dead end).

Decouple identity-stamping from signing entirely: after npm run pack,
run rcedit ourselves on the produced exe.
- Add rcedit as a direct devDependency of apps/desktop (the transitive
  electron-winstaller copy is fragile).
- apps/desktop/scripts/set-exe-identity.cjs: Node helper that calls
  rcedit's named export to set icon + ProductName/FileDescription/
  CompanyName. Node builds argv natively — avoids the PowerShell->exe
  ->JSON double-escaping that broke the app-builder rcedit path.
- install.ps1 Set-DesktopExeIdentity invokes the script after the build,
  before shortcuts. Best-effort: failure keeps the stock icon, never
  fails the install. rcedit is a pure PE editor — no signtool, no
  winCodeSign, no symlinks.

Verified locally: stamping a copy of the built Hermes.exe embeds the
32x32 icon and sets ProductName=Hermes.

Also fix update-path success-screen flash: in update mode the installer
hands off + exits in ~600ms, so don't route to the 'launch Hermes'
success view (it flashed before the window closed).

* update test

* fix(desktop): show 'hermes update' guidance for CLI installs instead of dead-end error

A user who installed via the CLI (irm|iex / install.sh) then ran
`hermes desktop` has no staged hermes-setup.exe, so clicking Update
in-app hit resolveUpdaterBinary()=null and showed a misleading error
('re-run the Hermes installer') with a Try-again button that could
never succeed — a dead loop for a perfectly valid install.

Treat the no-updater case as an intentional outcome, not a failure:
- main.cjs applyUpdates returns { ok:true, manual:true, command:'hermes update' }
  (no throw, no 'error' stage) when no updater binary exists.
- New 'manual' update stage + apply-state.command thread the command to the UI.
- updates-overlay ManualView: a polished terminal-native card with the
  exact command and a copy button, framed as the correct path for a CLI
  user rather than an error.

GUI-installer users are unaffected — hermes-setup.exe present => seamless
auto-update runs as before. Zero new process orchestration; can't fail
the update demo.

* update test

* fix(gui): pin /api/hermes/update to the current branch

The desktop command-center 'update' action hits POST /api/hermes/update,
which spawned bare `hermes update` with no --branch. cmd_update then
falls back to its default (main) and checks the working tree OUT of the
tracked branch — a bb/gui install silently jumped to main and lost the
desktop CLI.

Resolve the checkout's current branch and pass --branch <current> from
this endpoint only. The engine default (main) is DELIBERATELY unchanged:
bare `hermes update` from a terminal, the gateway /update bot command,
and the CLI/TUI relaunch path all keep their long-standing 'update against
main' contract for the existing user base. Only the GUI button is scoped
to update-the-branch-you're-on. Detached HEAD / git failure falls back to
the bare default.

* update test

* fix(desktop): branch-pin the CLI manual-update command card

The 'Update from your terminal' card (shown to CLI installs with no staged
updater) hardcoded bare `hermes update` — which defaults to main and would
switch a bb/gui (or any non-main) checkout off-branch. Same bug we fixed for
the GUI button, leaked into the card's copy text.

Resolve the checkout's current branch and show `hermes update --branch
<current>` for non-main checkouts; keep it bare for main so the card stays
clean. Best-effort: bare fallback if branch detection fails. Matches the
GUI button + installer --update contract; bare terminal/bot/TUI update
paths still default to main, unchanged.

* docs: phragg was here

* feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970)

- Feature Nous Portal as the primary onboarding card (Recommended tag,
  app logo, single pitch line); collapse other OAuth providers behind an
  "Other providers" disclosure whose open/closed state persists.
- Surface OpenRouter as a one-click API-key option inside the disclosure;
  move "I have an API key" to a quiet bottom-right link.
- Treat "no provider configured" as a normal onboarding state, not a red
  error banner (provider-setup-errors copy match).
- Fix setup.runtime_check: it reported ready when the resolved runtime had
  an empty credential or only implicit Bedrock/IAM, so fresh installs never
  saw onboarding. Now requires a usable credential.
- Auto-wire Windows fonts for WSL2 users so the renderer renders real
  Segoe UI instead of the DejaVu fallback; make WSL detection env-independent
  via the /proc kernel marker.

* feat(desktop): live elapsed timer on install bootstrap steps

The first-launch install overlay showed a static "Installing" with no
motion, so long steps (notably the repo clone) looked frozen. Stamp each
stage's start time on the running transition and tick once a second so the
active step shows live elapsed (e.g. "Installing · 1:23"), plus elapsed on
the overall current-step line. Completed steps keep their final duration.

* fix(desktop): resolve PortableGit for update checks + reserve titlebar tools space

- runGit() hardcoded spawn('git'), which ENOENTs on fresh installer-driven
  Windows installs (git is PortableGit under %LOCALAPPDATA%\hermes\git, never
  on PATH) — so "Check for updates" failed with "Couldn't check for updates".
  Add resolveGitBinary() mirroring findGitBash (PortableGit → Git-for-Windows
  → PATH) and use it in runGit.
- PageSearchShell rendered a full-width search input in the titlebar row, so
  on Windows its right edge slid under the fixed top-right tools + native
  window controls. Reserve that footprint via --titlebar-tools-* vars.

* fix(desktop): stop streaming caret from shifting layout on completion

The streaming caret (::after on the running message's last child) was an
in-flow inline-block adding ~0.78em of inline width, which could wrap the
last line mid-stream; when the caret is removed on completion the line
un-wraps and reflows — the visible post-response layout shift. Net-zero its
inline advance with a compensating negative margin so it paints at the text
end without consuming layout width.

* fix(desktop): stop completed-message layout shift while streaming

The assistant message action bar used `hideWhenRunning`, which unmounts it
whenever the thread is streaming. Since the bar reserves vertical space in
each completed assistant message's footer (it's invisible-until-hover via
opacity, not via mount), unmounting it collapsed every prior turn by the
bar's height — then remounting on resolve grew them back, shifting the whole
conversation (visible as "padding appears above the last user message").
Drop hideWhenRunning so the footer height is constant; the bar stays
invisible during streaming via its existing opacity/pointer-events gating.

* fix(merge): keep windows-footgun suppressions inline

* fix(merge): keep remaining gateway footgun suppressions inline

* fix(merge): restore contracts caught by main-target CI

* fix(dashboard): honor injected HERMES_DASHBOARD_SESSION_TOKEN

The desktop shell mints a session token and signs its /api + /api/ws
calls with it via HERMES_DASHBOARD_SESSION_TOKEN, but the main-merge
restored a web_server.py that ignored the env var and minted its own
random _SESSION_TOKEN -- so every desktop request 401'd and the UI
reported "gateway offline". Read the injected token (fall back to a
fresh random one) so loopback HTTP + WS auth line up.

Adds a regression test so a future merge can't silently drop the read.

* fix(desktop): align fresh-install home so upgraders don't brick

Two related first-launch bugs on machines with a legacy ~/.hermes:

- install.ps1 hardcoded $HermesHome/$InstallDir to %LOCALAPPDATA%\hermes
  and ignored the HERMES_HOME the desktop passes through. The desktop
  freezes HERMES_HOME at module load and prefers a legacy ~/.hermes when
  %LOCALAPPDATA%\hermes is absent, so the installer wrote to a different
  home than the shell read -> "Could not connect to Hermes gateway". Honor
  $env:HERMES_HOME in the param defaults.

- isBootstrapComplete() trusted the marker + checkout without verifying a
  runnable venv, so an interrupted/split install spawned a dead backend
  instead of re-bootstrapping. Also require the venv python to exist.

* fix(dashboard): allow packaged desktop file:// origin on loopback WS

The packaged Electron desktop loads its renderer over file://, so its
/api/ws handshake carries Origin: file:// (or null). The DNS-rebinding
WebSocket Origin guard only accepted http(s) origins matching the bound
host, so it rejected the desktop's own renderer with 4403 -> "Could not
connect to Hermes gateway" on macOS.

A browser DNS-rebinding attacker can only ever present an http(s) origin
(the site hosting the malicious page); it cannot forge file://, null, or
a custom app scheme AND hold the loopback session token. So on loopback
binds we now trust non-web origins -- the token in _ws_auth_ok remains
the real authenticator. Public/gated binds still reject them, and
cross-site http(s) origins are still rejected everywhere.

* fix(desktop): resolve renderer assets relative to BASE_URL

Absolute public asset paths (/apple-touch-icon.png, /ds-assets/...) work
under the dev server but break in the packaged app, where the renderer is
loaded from file://.../index.html and a leading slash resolves to the
filesystem root -> broken onboarding provider icon and backdrop image on
macOS. Prefix these with import.meta.env.BASE_URL so they resolve next to
the bundled index.html in both dev and packaged builds.

* feat(desktop): automate first-launch bootstrap on macOS/Linux

Previously a packaged macOS/Linux app with no Hermes install hit a
dead-end ("first-launch install is not yet automated -- run install.sh
manually") because install.sh lacked the staged protocol install.ps1
exposes. Now both platforms bootstrap on first launch with the same
structured, per-step progress UI as Windows.

- install.sh: add --manifest / --stage / --json / --non-interactive plus
  a stage dispatcher (prerequisites, repository, venv, python-deps,
  node-deps, path, config, setup, gateway, complete). User-input stages
  (setup, gateway) are skipped under --non-interactive; the in-app
  onboarding overlay owns API keys/model, matching the Windows flow.
  Each stage runs inside the install dir (its own process) and a new
  --commit flag pins the checkout to the build-stamp SHA.
- bootstrap-runner.cjs: drive the staged manifest/stage/JSON protocol for
  both install.ps1 (PowerShell) and install.sh (bash), selected by
  installer kind; removed the single-blob POSIX shim.
- main.cjs: drop the macOS/Linux unsupported-platform dead-end so the
  bootstrap-needed path runs the installer on every platform.

* fix(dashboard): return 404 JSON for unmatched /api paths instead of SPA HTML

The SPA catch-all (serve_spa) served index.html for any unmatched GET,
including unregistered /api/* endpoints. A missing API route therefore
came back as <!doctype html> with status 200, and JSON clients (the
desktop app's fetchJson) crashed with an opaque
'SyntaxError: Unexpected token <' instead of a clear error.

- web_server.py: unmatched /api or /api/... now returns 404 JSON
  ('No such API endpoint'); non-api paths still serve the SPA for
  client-side routing.
- main.cjs fetchJson: detect an HTML body / text/html content-type on a
  2xx response and reject with a clear message naming the URL, rather
  than a raw JSON.parse SyntaxError. Empty bodies resolve to null;
  malformed JSON reports the URL plus a snippet.

* say 'OS appearance' instead of 'macOS appearance'

* feat(install): add --include-desktop stage + PowerShell-style flags to install.sh

Brings install.sh to parity with install.ps1's bootstrap surface so the
shared Rust/Tauri bootstrapper (apps/bootstrap-installer) can drive a
macOS/Linux install the same way it drives Windows.

- Accept the PowerShell-style aliases the bootstrapper emits to both
  installers: -Commit / -Branch (alongside existing -Manifest / -Stage /
  -Json / -NonInteractive).
- Add --include-desktop / -IncludeDesktop. When set, the manifest gains a
  'desktop' stage (immediately before 'complete'), and a new install_desktop
  runs a root workspace `npm install` + `npm run pack` (electron-builder
  --dir, signing auto-discovery disabled) to produce release/mac*/Hermes.app
  -- mirroring install.ps1's Install-Desktop / Stage-Desktop.
- The flag is opt-in, exactly like Windows: the signed bootstrap installer
  passes it; the Electron app's own first-launch bootstrap and the CLI
  one-liner omit it (building the desktop from inside the running app would
  clobber it).

* fix: tts endpoints

* macOS desktop: install + in-app self-update (#35607)

* fix(installer): align macOS HERMES_HOME with the rest of the stack

paths.rs computed the macOS Hermes home as ~/Library/Application Support/
hermes, but nothing else does: hermes_constants.get_hermes_home() (Python),
scripts/install.sh, and the Electron desktop's resolveHermesHome() all use
~/.hermes on macOS. The drift meant the Tauri installer wrote the install to
one directory and the desktop looked for it in another, so a fresh GUI
install never found its backend (the file's own comment warned this exact
drift would break things). Use ~/.hermes on macOS to match.

* fix(install.sh): always emit a stage result frame on failure

Stage helpers (clone_repo, install_deps, check_python, …) were written for
the monolithic flow and call `exit 1` on failure. Under `--stage`, that
terminated the process before the JSON result frame was printed, so the
installer's parse_stage_result saw "no frame" instead of a clean
{ok:false,...} contract response. Run the stage body in a subshell so an
`exit` only unwinds the subshell and the parent still emits the frame.

* feat(install.sh): auto-provision git on macOS/Linux (parity with install.ps1)

install.ps1 downloads PortableGit on Windows, but install.sh just printed a
"please install git" hint and exited — so a fresh Mac with no developer tools
(no Xcode CLT → no git) couldn't get past the clone step. check_git now tries
to install git before bailing:
  - macOS: Homebrew if present (headless), else `xcode-select --install`
    (the CLT prompt also provides the compiler some wheels need), polling for
    git to appear.
  - Linux: apt/dnf/pacman via sudo when available.
Falls back to the manual instructions only if auto-provision fails.

* feat(desktop): in-app GUI+backend self-update on macOS/Linux

On Windows the staged Hermes-Setup binary drives updates (quit → hermes
update → hermes desktop --build-only → relaunch). The mac drag-install has no
such binary, so "Update now" previously just printed `hermes update`.

Since there's no venv-shim file lock on POSIX, the desktop can drive the whole
update itself. applyUpdates now, when no staged updater exists on mac/linux:
  1. runs `hermes update --yes [--branch <current>]` (backend git pull + deps),
  2. runs `hermes desktop --build-only` (OS-aware GUI rebuild) with the
     Hermes-managed Node + venv on PATH,
  3. spawns a detached swapper that waits for this process to exit, dittos the
     freshly built Hermes.app over the running bundle, clears quarantine, and
     relaunches.
Degrades to "backend updated — restart to load the new GUI" if the rebuild
fails or there's no .app bundle to swap (dev run, Linux AppImage).

* chore: uptick

* chore: uptick

* chore: linux build

* fix(install): detect xcode-select git stub on fresh macOS

* chore: bump

* fix(desktop): repair voice dictation on Windows

Voice dictation was broken on Windows in two ways:

1. Mic access was denied. The Electron permission request handler only
   granted 'media' requests whose details.mediaTypes included 'audio',
   but Chromium on Windows frequently fires the mic request with an empty
   mediaTypes array, so getUserMedia threw NotAllowedError. The handler
   now grants audio-capture when mediaTypes includes 'audio' OR is
   empty/absent, handles the 'audioCapture' permission name, and adds a
   setPermissionCheckHandler (the synchronous path Chromium also consults
   for getUserMedia on Windows). Video is still denied.

2. Transcripts went nowhere. The composer's insertText handler (used by
   dictation and other inserts) only updated the assistant-ui composer
   store via setText, never the contentEditable editor DOM. The
   draft->editor sync effect only re-renders the editor when it is NOT
   focused, and dictation runs while the editor has/regains focus, so the
   transcript was stored but never shown and could not be sent. insertText
   now renders into the editor DOM and places the caret, mirroring
   appendExternalText.

Also hardens fetchJson: a 2xx response with an HTML body (or text/html
content-type) now rejects with a clear message naming the URL instead of
an opaque JSON.parse 'Unexpected token <' error.

* feat(desktop): route Nous subscribers onto the Tool Gateway from the GUI

When the GUI sets the main provider to Nous via POST /api/model/set, call
the same apply_nous_managed_defaults the CLI uses after model selection, so
GUI/onboarding users land on the Nous Tool Gateway the same way CLI users do
— no separate prompt, no duplicated logic.

Purely additive: apply_nous_managed_defaults skips any tool where the user
has a direct key (FIRECRAWL_API_KEY, FAL_KEY, etc.) or explicit config, so it
never overwrites a user's own setup. Only unconfigured tools get routed.

- web_server.py: in set_model_assignment (scope=main, provider=nous), resolve
  enabled toolsets and apply managed defaults; guarded so a Portal hiccup never
  blocks saving the model. Returns routed tools as gateway_tools.
- onboarding.ts: surface a 'Tool Gateway enabled' toast listing routed tools.
- types/hermes.ts: add gateway_tools to ModelAssignmentResponse.
- tests: cover nous-applies, non-nous-skips, and failure-doesnt-block-save.

* feat(desktop): mirror hermes model free/paid curation in GUI onboarding

GUI onboarding picked models[0] from /api/model/options, which ignores the
Nous free/paid tier — a free user could land on a paid default (e.g.
anthropic/claude-opus-4). Now the recommended default mirrors what `hermes
model` does.

- web_server.py: new GET /api/model/recommended-default?provider=<slug>. For
  Nous it runs the same curation as the CLI (get_curated_nous_model_ids +
  pricing + check_nous_free_tier + union_with_portal_{free,paid}_recommendations
  + partition_nous_models_by_tier) so free users get a free model and paid users
  get the curated default. Other providers fall back to the first curated model.
  Never 500s — returns empty model on error so onboarding degrades gracefully.
- hermes.ts: getRecommendedDefaultModel client + RecommendedDefaultModel type.
- onboarding.ts: fetchProviderDefaultModel prefers the recommended endpoint,
  falls back to models[0] when unavailable.
- tests: free-tier picks free model, paid-tier picks curated default, failure
  returns empty without 500.

* feat(desktop): show model pricing + free/paid tier gating in GUI picker

The CLI `hermes model` picker shows per-model $/Mtok pricing and gates paid
models on free Nous accounts. The GUI picker showed bare model names. Bring it
to parity across both the model-picker dialog and onboarding confirm card.

Backend:
- inventory.build_models_payload gains a pricing=True flag → _apply_pricing
  enriches each provider row with formatted per-model pricing
  ({input,output,cache,free}) via the same _format_price_per_mtok the CLI uses,
  and for Nous adds free_tier + unavailable_models (paid models a free user
  can't select) via check_nous_free_tier + partition_nous_models_by_tier.
  Best-effort: any pricing/tier failure is swallowed and fails open (no gating).
- /api/model/options and TUI model.options now pass pricing=True so the
  global picker and in-session picker both carry pricing.

Frontend:
- ModelOptionProvider gains pricing/free_tier/unavailable_models; new
  ModelPricing type.
- model-picker dialog renders In/Out $/Mtok (or a Free pill) per model, a
  Free tier/Pro badge on the Nous heading, and disables + grays unavailable
  paid models for free users with a 'Pro models need a paid subscription' note.
- onboarding confirm card shows the chosen model's price + tier badge.

Tests: test_inventory_pricing covers price formatting, free-tier gating,
paid no-gating, providers without pricing, and swallowed failures.

* fix(desktop): GUI model picker shows curated Nous list in curated order

Two bugs made the GUI Nous model list diverge from the `hermes model` CLI picker:

1. Backend (model_switch.py): the Nous row in list_authenticated_providers
   fell through to cached_provider_model_ids("nous"), dumping the full live
   /v1/models catalog (~50 vendor-prefixed models, alphabetical). Now it uses
   the curated list AND applies the Portal free/paid recommendation union —
   exactly like _model_flow_nous in main.py — so newly-launched models such as
   stepfun/step-3.7-flash:free surface in curated order. Best-effort: falls
   back to the curated list alone if the Portal fetch fails.

2. Frontend (model-picker.tsx): cmdk's Command had shouldFilter on (default),
   which re-sorts items by fuzzy-match score (≈alphabetical) and ignores array
   order. Set shouldFilter={false} + own the search term and do an
   order-preserving substring filter, so the backend's curated order is shown
   verbatim.

* feat(desktop): add/switch providers from the model picker via onboarding reuse

The model picker could only select models from already-authenticated
providers. Switching to a new provider had no in-app path. Rather than
duplicate provider UI, reuse the existing onboarding provider selector
(featured Nous + other providers + API-key form + device-code/PKCE flow +
model-confirm with pricing/tier).

- onboarding store: add a 'manual' flag with startManualOnboarding() /
  closeManualOnboarding(). Manual mode forces the onboarding overlay to show
  even when configured===true and refreshOnboarding no longer auto-dismisses
  on runtime-ready (the app is already working — the user is just adding or
  switching a provider).
- onboarding overlay: render when manual even if configured; show a Close
  button (the first-run flow has none since the app can't run yet).
- model picker: 'Add provider' footer button opens the onboarding selector;
  ModelResults lists only configured (model-bearing) providers.

* feat(desktop): add PUT /api/tools/toolsets/{name} enable/disable endpoint

* feat(desktop): add toggleToolset RPC binding

* feat(desktop): toolset enable/disable switch in Tools settings

* feat(desktop): tool configuration parity in GUI Tools settings

Bring the desktop GUI Tools settings to parity with the CLI `hermes tools`
for provider selection and API-key configuration.

Backend (hermes_cli/web_server.py):
- GET  /api/tools/toolsets/{name}/config  - provider matrix + key status
- PUT  /api/tools/toolsets/{name}/provider - persist provider selection

Shared core (hermes_cli/tools_config.py):
- Extract apply_provider_selection / _write_provider_config from the
  interactive _configure_provider so the CLI and GUI write identical
  config keys (web.backend, tts.provider, browser.cloud_provider, plugin
  image/video providers, use_gateway flags) through one code path.

Desktop UI:
- ToolsetConfigPanel: provider list with select, per-provider API-key
  entry (set/replace/clear/reveal via the shared env RPCs), Ready/Needs
  keys state, guidance for Nous-auth and post-setup providers.
- Wire the Configured/Needs keys pill to expand the panel inline; refresh
  the toolset list after key changes so the pill updates live.
- Add getToolsetConfig / selectToolsetProvider RPC bindings + types.

Post-setup (OAuth/install) flows still defer to the CLI; see
docs spike findings for the planned /api/tools/setup/* endpoint family.

Tests: backend round-trip + 400 cases for the new endpoints and
apply_provider_selection; desktop vitest coverage for the config panel
(provider render, select, key save). No change-detector tests.

Also removes three stale completed plan docs.

* fix(desktop): show real Hermes version + sync package.json on release

The desktop app version was disconnected from the Hermes version: the
release script bumped pyproject.toml + hermes_cli/__init__.py but never
touched apps/desktop/package.json, which sat stale at 0.0.2 (lockfile at
0.0.1).

- main.cjs: hermes:version IPC now resolves __version__ from
  hermes_cli/__init__.py (the canonical source release.py bumps) via a new
  resolveHermesVersion() helper, falling back to app.getVersion() when the
  source tree isn't readable. The About panel now always shows the live
  Hermes version and can't drift.
- release.py: update_version_files() also bumps apps/desktop/package.json
  in lockstep with pyproject (top-level version only; dep specs untouched).
- One-time catch-up: package.json 0.0.2 -> 0.15.1 and the lockfile root
  mirrors 0.0.1 -> 0.15.1.

* fix(desktop): stamp exe identity in afterPack hook so updates stay branded

The packed Hermes.exe reverted to the stock Electron icon + "Electron" name
after an in-app update. The icon/identity stamp (rcedit) lived only in
install.ps1, but the installer's --update path rebuilds the desktop via
`hermes desktop --build-only` -> `npm run pack`, which never ran install.ps1
and so never stamped the rebuilt exe.

Move the stamp into an electron-builder afterPack hook so it runs for EVERY
packed build regardless of caller (first install, hermes desktop, the update
rebuild, or a manual npm run pack):

- set-exe-identity.cjs: refactor to export stampExeIdentity(exe, desktopRoot);
  still runnable as a standalone CLI.
- after-pack.cjs (new): afterPack hook calling stampExeIdentity. Windows-only
  guard; best-effort (logs + resolves on failure, never fails the build).
- package.json: register build.afterPack.
- install.ps1: remove the now-redundant Set-DesktopExeIdentity function + call;
  the hook handles it during npm run pack.

electron-builder's own rcedit step stays disabled (signAndEditExecutable=false)
to avoid the signtool -> winCodeSign -> 7-Zip macOS-symlink crash on non-admin
Windows; the hook runs rcedit directly (pure PE resource edit, no signing).

* fix(desktop): export afterPack hook as exports.default so electron-builder runs it

The afterPack hook used `module.exports = fn`, which electron-builder's hook
loader doesn't pick up — it expects the function as the module's default
export (the same shape afterSign/notarize.cjs uses). The hook silently never
ran, so even first install shipped the stock "Electron" exe.

Switch to `exports.default = async function afterPack(...)`. Verified with a
real `npm run pack`: electron-builder now invokes the hook and the produced
release/win-unpacked/Hermes.exe carries ProductName/FileDescription=Hermes.

* chore(desktop): drop auto-build release CI in favor of manual build + upload

Remove desktop-release.yml (nightly-on-main + stable publish). Installers
are now built locally per platform and uploaded to a GitHub Release by hand;
the website points at them via NEXT_PUBLIC_HERMES_DL_* env. Update README +
docs and drop the dead desktop-nightly channel links.

* fix(desktop): stable shortcut icon + bust icon cache so updates repaint

Symptom on a freshly-installed laptop: Hermes.exe itself shows the correct
Hermes icon (Explorer reads the live exe's stamped PE resource), but the
desktop shortcut still draws the stock Electron icon.

Cause: New-DesktopShortcuts set IconLocation to "<exe>,0", so Windows cached
the icon it extracted from the exe at shortcut-creation time. On an update the
exe gets re-stamped, but the shortcut keeps rendering the stale cached bitmap.

- package.json: ship assets/icon.ico beside the exe via extraResources
  (-> resources/icon.ico). Verified with a real npm run pack.
- install.ps1 New-DesktopShortcuts: point IconLocation at resources/icon.ico
  (fallback to <exe>,0 if absent) — a dedicated .ico is cache-stable and skips
  the per-exe extraction that goes stale. Then run `ie4uinit.exe -show` to bust
  the shell icon cache so the shortcut repaints immediately instead of showing
  the old Electron icon until reboot.

Both best-effort; never fail an otherwise-good install.

* dummy update

* feat(desktop): self-heal update branch + backend contract guard

Two fixes for the bb/gui→main transition:

- Self-update self-heals: if the tracked branch (e.g. bb/gui) no longer
  exists on origin (merged + deleted), the desktop updater falls back to
  main and persists it. Read-only ls-remote probe that only flips on a
  definitive "ref absent" (exit 2), never on a transient network error, so
  already-installed clients migrate themselves with no manual flip.
- Backend contract guard: tui_gateway reports DESKTOP_BACKEND_CONTRACT in
  session runtime info; the desktop warns with a one-click "Update Hermes"
  when the backend predates the GUI's required contract (e.g. a bb/gui app
  pointed at a main checkout) instead of failing cryptically downstream.

* docs(desktop): rewrite README to match current install/update/build flow

The old README contradicted itself (claimed a bundled Python payload while
also saying it no longer bundles source) and predated cross-platform support.
Rewrite for accuracy: Linux is a first-class build target, install.sh/install.ps1
both drive the staged bootstrap, the real self-update handoff (Windows
Hermes-Setup vs in-app macOS/Linux), and the bb/gui→main self-heal + backend
contract guard.

* docs(desktop): rewrite README as a real product readme

Lead with what the app is and how to get it (download an installer, or
`hermes desktop` for existing CLI users) plus a plain-language feature list,
then keep contributor/build/internals as a clearly separated secondary section.

* docs(desktop): fix install framing — releases no longer auto-build installers

Lead with the install-with-Hermes path (`--include-desktop` / `hermes desktop`),
which always works, and describe prebuilt installers as manually published when
a release ships them rather than implying CI attaches them to every release.

* docs(desktop): match base repo README style

Adopt the root README's conventions: centered title + badge row, bold
one-liner intro, a feature <table> grid, --- section dividers, and a
Community / License footer.

* feat(desktop): recover from gateway boot failures + validate API keys on entry (#35864)

Fresh installs that hit a gateway boot failure had no recovery path: the
shell rendered dead ("gateway offline"), logs were undiscoverable, and a
mistyped API key was accepted because onboarding only checked credential
presence, not validity.

- Add BootFailureOverlay: a top-level recovery surface (Retry, Repair
  install, Use local gateway, Open logs + inline recent logs) that mounts
  on any hard boot failure, including post-install. Trims the now-redundant
  recovery button from the onboarding Preparing panel.
- Add hermes:logs:reveal / :recent IPC (reveal desktop.log) and a
  hermes:bootstrap:repair IPC that drops the bootstrap marker to force a
  clean reinstall. Surface "Open logs" in Gateway settings too.
- Add POST /api/providers/validate: a live per-provider probe
  (OpenRouter/OpenAI/xAI/Gemini key check, local endpoint connectivity)
  wired into saveOnboardingApiKey so a rejected key blocks before it's
  persisted, while an unreachable probe falls through (offline-safe).

* test(model-catalog): fix stale nous picker test after curated-list change

ac2e48907 made the GUI/picker Nous row use the curated list (curated["nous"]
= get_curated_nous_model_ids()) + Portal union, matching the `hermes model`
CLI — but test_picker_nous_row_uses_manifest still asserted the old 2-model
manifest snapshot, breaking the test shard.

Rewrite it as an invariant: stub the Portal union to passthrough and assert the
row equals get_curated_nous_model_ids() computed under the same conditions, so
it tracks the real contract instead of a hardcoded model list that rots on every
catalog update.

---------

Co-authored-by: emozilla <emozilla@nousresearch.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: ethernet <arilotter@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-31 17:46:56 -05:00
kshitijk4poor 6f8975dcd8 fix(tools): don't compound-rewrite spawn_via_env background wrappers
Background tasks on non-local backends (SSH/Docker/Modal/Daytona/Singularity)
go through `ProcessRegistry.spawn_via_env`, which builds a hand-crafted,
shell-safe wrapper:

    mkdir -p T && ( nohup bash -lc CMD > LOG 2>&1; rc=$?; ... ) & echo $! > PID && cat PID

`BaseEnvironment.execute()` unconditionally ran `_rewrite_compound_background`
on every command, including this wrapper. The rewrite (meant to defuse the
`A && B &` subshell-wait trap for user commands) turns `( ... ) & echo $!` into
`{ ( ... ) & } echo $!` — note `} echo` with no separator, which is a bash
syntax error. The wrapper then never produces a PID, the redirected output file
is never created, and the agent sees an immediate exit code -1. This breaks
*every* background launch on a non-local backend (e.g. a simple
count-and-redirect script over SSH), not just edge cases.

Fix:
- Add `rewrite_compound_background: bool = True` to `BaseEnvironment.execute()`
  (and the `BaseModalExecutionEnvironment` override, which accepts and ignores
  it). Default preserves existing behavior; the user foreground terminal path
  still rewrites.
- `spawn_via_env` passes `rewrite_compound_background=False` so its already
  shell-safe wrapper is left intact.
- Treat a wrapper that produces no PID as a failed launch (mark the session
  exited with a real exit code instead of exposing a fake running session), and
  don't register/checkpoint a session that never started.

Verified empirically: with the rewrite skipped, the wrapper is valid bash,
launches the process, captures the PID, and writes the log/pid/exit files; the
old rewritten form fails `bash -n` with a syntax error.

Based on #33756 by @CharZhou (extracted from a multi-feature branch; the
unrelated image_gen / docker-media changes are not included here).

Co-authored-by: CharZhou <17255546+CharZhou@users.noreply.github.com>
2026-06-01 00:05:10 +05:30
teknium1 04bb74c58e chore: map fesalfayed author email for release notes 2026-05-31 06:14:34 -07:00
kshitijk4poor 32899279a7 fix(gateway): detach pending_watchers batch + normalize LRU caches + align test fixtures + AUTHOR_MAP
Self-review follow-up on top of the salvaged perf fixes:

- gateway/run.py (both watcher-drain sites): the salvaged O(n^2) fix
  (#32708) replaced `while pending_watchers: pop(0)` with iterate-then-
  `watchers.clear()`, but `watchers` aliased the registry's live list.
  A watcher appended by a concurrent session during the `await
  asyncio.sleep(0)` yield would be cleared without ever being scheduled.
  Detach the batch atomically (`pending_watchers = []`) before iterating.

- gateway/platforms/bluebubbles.py: normalize the salvaged _guid_cache
  LRU (#30523) to match feishu/codebase precedent — module-level
  `_GUID_CACHE_SIZE` constant, `while len > cap`, and drop the redundant
  post-insert `move_to_end` (a fresh insert is already most-recent).

- gateway/platforms/feishu.py: drop the same redundant post-insert
  `move_to_end` from the salvaged _message_text_cache LRU (#23706).

- scripts/release.py: add AUTHOR_MAP entries for the salvaged commits'
  authors (amathxbt #22155, ErnestHysa #32636/#32708) so the contributor
  audit passes when these commits land on main.

- tests/tools/test_tool_output_limits.py: autouse fixture resets the new
  module-level limits cache between tests.

- tests/gateway/test_feishu.py: hand-built adapter fixture seeded
  _message_text_cache as a plain dict; it's now an OrderedDict, so the
  fixture type had to match.
2026-05-31 00:50:19 -07:00
Teknium ec67def5bf
fix(install): refresh stale uv so installs actually get FTS5 Python (#35541)
The installer's ensure_fts5() handled a no-FTS5 Python by running
'uv python install --reinstall', but WHICH Python builds a uv can
install is baked into the uv binary's download manifest. A stale uv
(e.g. 'pip install uv==0.7.20', which predates python-build-standalone
#694) only knows about pre-FTS5 builds, so --reinstall just pulls the
same FTS5-less interpreter — a no-op for FTS5. Result: 'Could not obtain
an FTS5-capable Python' and a broken session search even on the
supported installer path.

ensure_fts5() now escalates uv itself: reinstall with current uv ->
'uv self update' + reinstall (stale standalone uv) -> install a fresh
standalone uv into a temp dir and reinstall with that (externally-managed
uv that can't self-update, the reported case). Pythons live in uv's
shared store, so the fresh uv's --reinstall overwrites the stale
interpreter in place and the installer's later 'uv python find' resolves
to the FTS5-capable build.

Verified against the reporter's exact repro (ubuntu:24.04 +
pip install uv==0.7.20): Python 3.11.13 (no FTS5) -> 3.11.15 (FTS5).
2026-05-30 18:59:05 -07:00
teknium 433bffff51 fix(cli): surface oneshot agent exceptions to stderr with rc=1
Layer an exception guard on top of the empty-response fix so a crash
inside the agent (e.g. OSError from prompt_toolkit/Vt100 when stdout is a
non-TTY pipe, per #30623) is surfaced on the real stderr with rc=1 instead
of crashing past the redirect_stderr block. KeyboardInterrupt/SystemExit
are re-raised so Ctrl-C and explicit exits still propagate.

Also map briancl2 in scripts/release.py AUTHOR_MAP for the cherry-picked
empty-response commit.

Adapts the exception-guard approach from sweetcornna's PR #33818.

Co-authored-by: sweetcornna <96944678+ymylive@users.noreply.github.com>
2026-05-30 07:31:48 -07:00
Teknium 42bbd221e8 fix(compressor): strip stale handoff prefix on resume; reconcile #26290+#32787 (#35344)
A handoff persisted under an older SUMMARY_PREFIX can be inherited into a
resumed lineage. _strip_summary_prefix only matched the current/legacy
literal, so on re-compaction the old 'resume exactly from Active Task'
directive stayed embedded in the body and kept hijacking replies to new,
unrelated user messages.

- Add _HISTORICAL_SUMMARY_PREFIXES (pre-#35344 prefix) and strip/recognize
  them in _strip_summary_prefix + _is_context_summary_content so resumed
  stale handoffs are re-normalized to the current latest-message-wins prefix.
- Reconcile the overlapping Active Task template edits from the salvaged
  #26290 (reverse-signal cancellation) and #32787 (capture open questions /
  decisions, don't write None too eagerly) — both intents kept.
- Regression coverage in tests/agent/test_resume_stale_active_task.py.
- AUTHOR_MAP entries for both salvaged contributors.
2026-05-30 07:29:21 -07:00
teknium1 44f3e51865 fix(gateway): run adapter config hooks for nested-only platform blocks
The plugin apply_yaml_config_fn dispatch loop only ran when a top-level
platform block (e.g. `discord:`) existed. Configs that defined a platform
only under `platforms.<name>` or `gateway.platforms.<name>` skipped the
hook, so `platforms.discord.extra.allow_from` never reached
DISCORD_ALLOWED_USERS. Fall back to those nested blocks when the top-level
one is absent.

Also map byquenox@gmail.com -> Que0x for the salvaged commits.
2026-05-30 05:23:55 -07:00
teknium1 e1945ff697 test(state): cover update_session_model overwrite + getattr-guard text path
Follow-up to LengR's #35181 salvage:
- gateway text-path uses getattr(self, '_session_db', None) to match the
  picker callback path (defensive for object.__new__() gateway test pattern).
- add SessionDB.update_session_model test asserting it overwrites the
  COALESCE-pinned model and survives subsequent token updates (#34850).
2026-05-30 02:35:36 -07:00
teknium1 c9e31a8e4b chore(release): map tuancookiez-hub for #34865 salvage 2026-05-30 02:08:36 -07:00
teknium1 4d7ea3fd36 chore(release): map inchargeautomation-lab author email 2026-05-30 02:08:11 -07:00
teknium1 e5765e61fa chore(release): map wei.chen.coder@gmail.com -> wenchengxucool 2026-05-30 00:30:55 -07:00
teknium1 17103a1f11 chore: add SeaXen to AUTHOR_MAP for salvaged PR #33278 2026-05-30 00:23:44 -07:00
teknium1 4fa20f9a8b fix(install): ensure the uv-managed Python ships SQLite FTS5
uv's python-build-standalone distributions only gained FTS5 in mid-2025
(#694). A stale interpreter already in uv's store — which `uv python find`
reuses without checking — can lack it, leaving the supported install with
a SQLite that can't create the FTS5 virtual tables hermes_state.py needs
for full-text session search ("no such module: fts5").

check_python now probes the resolved interpreter for FTS5 and, if missing,
reinstalls the latest patch for $PYTHON_VERSION (which has FTS5) and
re-resolves. If an FTS5-capable Python still can't be obtained (offline,
pinned env), it warns and continues — Hermes degrades gracefully and only
disables session search. No bundled second SQLite, no user action.
2026-05-29 20:11:07 -07:00
teknium1 bb50825716 chore(release): map annguyenNous to AUTHOR_MAP
Clears the check-attribution CI gate on PR #34468 — the contributor's
noreply email was unmapped.
2026-05-29 13:29:34 -07:00
Teknium 781604ce4c
fix(gateway): unify MEDIA: extraction extension set + close the unknown-ext black hole (#34517) (#34844)
MEDIA:<path> tags for .md/.json/.yaml/.xml/.html and other document
extensions were silently dropped. extract_media() carried a narrow
extension allowlist that omitted them, while extract_local_files()
had a broad one. The dispatch sites then ran an unconditional
re.sub(r'MEDIA:\\s*\\S+', '') that stripped the tag from the body even
when extract_media had not matched it — so extract_local_files (broad
list) ran on text where the path was already gone, and the file was
delivered by neither path.

- Add MEDIA_DELIVERY_EXTS in gateway/platforms/base.py as the single
  source of truth; extract_media and extract_local_files both derive
  their extension set from it (no more drift).
- Replace the loose MEDIA cleanup at the non-streaming dispatch site
  (base.py) and the streaming consumer (stream_consumer.py) with the
  shared, extension-anchored MEDIA_TAG_CLEANUP_RE. A MEDIA: tag with an
  unknown extension is left in the body so the bare-path detector can
  still pick it up instead of being black-holed.
- Chain cleaned text through extract_media -> extract_images ->
  extract_local_files in run.py's post-stream media delivery (it was
  dropping the cleaned text and rescanning raw text with MEDIA: tags).
- Regression tests covering both halves: previously-dropped extensions
  now extract, and unknown-ext paths survive the cleanup.

Consolidates the MEDIA extension-allowlist PR cluster.

Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
Co-authored-by: banditburai <123342691+banditburai@users.noreply.github.com>
Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-05-29 13:24:01 -07:00
teknium1 0dc0c5ea6b chore: add AUTHOR_MAP entry for sweetcornna
Maps the cherry-picked commit's noreply email to the GitHub login so the
release attribution / CI author check passes.
2026-05-29 13:22:54 -07:00
teknium1 8055d0f092 test(ntfy): cover echo-tag filter; tag standalone send path
Adds tests for the echo-loop fix (outgoing X-Tags header, inbound skip
on tagged events, genuine tags pass through) and extends the tag to the
out-of-process _standalone_send() path so cron / send_message deliveries
to a self-subscribed topic are also skipped. Maps both contributors in
release.py AUTHOR_MAP.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-05-29 13:17:46 -07:00
zapabob 30a0d5bc9e chore(release): map zapabob author email 2026-05-29 12:32:35 -07:00
teknium1 5641ae6469 chore(release): add AUTHOR_MAP entries for Bucket-1 docs salvage contributors 2026-05-29 12:06:22 -07:00
teknium1 fd09b2c55e fix(gateway): trust adapter-owned access policy over env default-deny (#34515)
Config-driven platform policies (dm_policy / group_policy / allow_from /
group_allow_from) for WeCom, Weixin, Yuanbao, and QQBot now work without
also setting a PLATFORM_ALLOWED_USERS env var.

These adapters enforce their access policy at intake — a message is dropped
inside the adapter and never dispatched unless it already passed the policy.
The gateway's env-based check (_is_user_authorized) ran afterward and, with
no env allowlist set, fell through to an env-only default-deny — silently
rejecting `dm_policy: open` and config-only allowlists the adapter had
already authorized.

Rather than re-implement each adapter's policy a second time in run.py
(which would drift), adapters that own their gate now declare it via a new
BasePlatformAdapter.enforces_own_access_policy property (default False). The
gateway trusts that flag and skips the env-only default-deny for those
platforms. Env allowlists still take precedence when set.

Also resolves unauthorized DM behavior from config dm_policy so allowlist /
disabled policies drop unauthorized DMs silently instead of leaking pairing
codes, while an explicit pairing policy opts back in.

Co-authored-by: Frowtek <frowte3k@gmail.com>
2026-05-29 04:22:41 -07:00
teknium1 58e1b04665 chore(release): map tillfalko to GitHub login for PR #29987 salvage 2026-05-29 03:58:56 -07:00
teknium1 a87f0a82a5 test(tool-search): redact secrets from harness transcripts + console
The live harness runs against a real OpenRouter key; record['error'] is a
full traceback that, on an auth failure, could echo a request header or URL
containing the key. _redact_secrets() now masks the live OPENROUTER_API_KEY,
any sk-/sk-or- bearer token, and Authorization/Bearer headers before
final_response and error enter the transcript or the console print. Addresses
the CodeQL clear-text-storage/logging findings at the source.
2026-05-29 02:04:12 -07:00
teknium1 1709776120 test(tool-search): add live A/B harness, drop checked-in transcripts
Brings in the tool_search live-test harness from the original PR but leaves
out the 11 checked-in scripts/out/*.json transcript files — those are
non-deterministic model output that goes stale the moment the model changes
and were the bulk of the diff. scripts/out/ is now gitignored so a harness
run never re-commits them.

Fixes on top:
- API-key loading goes through hermes_cli.env_loader.load_hermes_dotenv
  instead of hand-parsing ~/.hermes/.env and assigning the value to a local.
  The canonical loader never materializes the secret in a local variable in
  this module, which clears the four CodeQL high alerts
  (py/clear-text-storage / py/clear-text-logging-sensitive-data at the
  transcript write/print sites — they were tracing the key from the
  hand-rolled parser into the records) and removes a hand-rolled parser.
- encoding='utf-8' on every write_text/read_text in both harness scripts
  (Windows-footgun hygiene).

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-05-29 02:04:12 -07:00
Teknium 0384398c65 chore(release): map blackpilledsoftware-prog email to GitHub login
Required by CI author validation after salvaging PR #16780.
2026-05-29 00:31:44 -07:00
Teknium c1485d52e3 chore(release): add moikapy AUTHOR_MAP for PR #31527 salvage 2026-05-29 00:28:02 -07:00
teknium1 8d57281650 chore: add AUTHOR_MAP entry for Interstellar-code 2026-05-29 00:21:54 -07:00
teknium1 592a4ffb6b fix(kanban): close three blocked/iteration-exhausted handling gaps (#29747)
Reporter diagnosed three independent gaps that together allowed infinite
'unblock → re-stuck' loops with no surfacing or escalation:

GAP 1: `_rule_stuck_in_blocked` resets timer on any `commented`/`unblocked`
event, so a task that cycles every few minutes is invisible to it
regardless of how many times it cycles.

Fix: new `_rule_block_unblock_cycling` rule (`hermes_cli/kanban_diagnostics.py`)
that counts block→unblock cycles in a sliding window. Default threshold
3 cycles within 24h, configurable via `block_cycle_threshold` /
`block_cycle_window_seconds`. Walks events in arrival order (event id)
since multiple events can share the same `created_at` second. Fires as a
warning with a CLI hint to inspect the block reasons.

GAP 2: Iteration-budget-exhausted runs in kanban workers map to
`kanban_block` (status=blocked, but a clean exit from the kernel's
perspective). `_rule_repeated_failures` reads `consecutive_failures`,
which `_record_task_failure` increments only for crashed/timed_out/
spawn_failed — `blocked` outcome bypasses the failure counter, so the
`kanban.failure_limit` circuit breaker never trips on budget-exhaustion
loops.

Fix: `agent/conversation_loop.py` budget-exhaustion path now calls
`_record_task_failure(outcome="timed_out")` instead of `kanban_block`.
Budget exhaustion is genuinely a timeout-shaped failure (the task ran out
of allowed iterations), so this is more honest semantics; it also routes
through the unified failure counter, so repeated budget exhaustions trip
the circuit breaker and the task auto-blocks with `gave_up` after
`failure_limit` retries.

GAP 3: `release_stale_claims` uses `_pid_alive(worker_pid)` only and
ignores `last_heartbeat_at`. Reporter observed a 91-min run that held
its claim with frozen heartbeat because the worker entered a logic loop
with no tool calls — `_pid_alive` kept returning True so the claim was
extended every 15 minutes indefinitely.

Fix: heartbeat-stale backstop. If `last_heartbeat_at` is set AND older
than `DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS` (default 1h), reclaim
even if the PID is alive. NULL `last_heartbeat_at` preserves backward
compatibility (no heartbeat yet = extend, as before). The reclaim event
payload now includes a `heartbeat_stale` boolean so operators see why a
live-PID worker was reclaimed.

This works cleanly in concert with PR #34418 (#31752 runtime → heartbeat
bridge): once `_touch_activity` keeps `last_heartbeat_at` fresh as a
side effect of normal API traffic, the backstop only fires for genuinely
wedged workers (no chunks, no tool results, no progress at all).

Co-authored-by: baofuen <45189813+baofuen@users.noreply.github.com>
2026-05-29 00:13:29 -07:00
teknium1 bc31ee5cf8 fix(kanban): bridge worker runtime activity to board heartbeat (#31752)
The dispatcher watchdog (release_stale_claims) reads tasks.last_heartbeat_at
to decide whether to reclaim a running task. The agent maintains its own
in-process `_last_activity_ts` for every chunk/tool result, but those
liveness ticks never reach the board unless the model explicitly calls
the `kanban_heartbeat` tool — so a worker actively executing a long run
without tool-level heartbeats can be reclaimed mid-flight as 'stale',
returning the task to ready and orphaning the in-flight worker's progress.

Fix: in `_touch_activity` (the canonical 'we just did work' hook in
run_agent.py), call a new `heartbeat_current_worker_from_env` helper
in `tools/kanban_tools.py` that:

- No-ops outside dispatcher-spawned worker context (no HERMES_KANBAN_TASK).
- Rate-limited to one DB write per 60s (runtime activity ticks too often
  to faithfully mirror; we just need the watchdog to see liveness).
- Best-effort: never raises. heartbeat_claim + heartbeat_worker calls are
  individually try/except'd; any DB error logs at debug and returns.
- Uses worker env identity: HERMES_KANBAN_TASK + HERMES_KANBAN_RUN_ID +
  HERMES_KANBAN_CLAIM_LOCK (all pinned by the dispatcher at spawn time).
- No durable note on auto-heartbeats — that's reserved for the explicit
  `kanban_heartbeat` tool which carries a model-supplied note.

The explicit `kanban_heartbeat` tool stays available unchanged for
workers that want to attach a note or pre-emptively extend a claim
across a known-long single tool call.

Co-authored-by: faisfamilytravel <223516181+faisfamilytravel@users.noreply.github.com>
2026-05-29 00:05:58 -07:00
teknium1 40217aa194 fix(kanban): tell workers not to use clarify; route to kanban_block instead (#32167)
Kanban workers run headless — no live user is on the other side of `clarify`,
so the call times out (~120s default) and the task sits silently in `running`
with no signal to the operator that input is needed. Reporter observed a real
incident where a worker asked 'promote to production, or check staging first?'
via clarify, the call timed out, the agent hallucinated a fallback, and the
task sat 'running' for hours.

Fix: explicit 'do not call clarify' bullet in two surfaces every kanban worker
sees —

- `agent/prompt_builder.py` KANBAN_GUIDANCE `## Do NOT` section (auto-injected
  into every dispatcher-spawned worker run).
- `skills/devops/kanban-worker/SKILL.md` `## Do NOT` section (the bundled
  worker skill).

Both point at the right pattern: `kanban_comment` (context) + `kanban_block`
(decision needed) — the task surfaces on the board as blocked, the operator
sees it, unblocks with their answer in a comment, and the worker respawns
with the thread.

Co-authored-by: kweiner <17778+kweiner@users.noreply.github.com>
2026-05-28 23:57:20 -07:00
teknium1 ae6817f7f7 fix(kanban): add --reason flag to unblock for symmetry with block (#30897)
`hermes kanban unblock <id> review-required: ...` parsed every trailing word
as another task_id (since `task_ids` is `nargs='+'`), then quietly failed on
each non-existent id with "cannot unblock review-required: (not blocked/scheduled?)".
Reporter saw this as asymmetric with `block <id> <reason...>` which accepts
positional reason words.

Fix: add a `--reason "..."` flag that, when provided, is appended as a
`UNBLOCK: <reason>` comment before the unblock transition. Bulk syntax
(`unblock t_a t_b t_c`) is preserved unchanged.

Co-authored-by: julio-cloudvisor <211828103+julio-cloudvisor@users.noreply.github.com>
2026-05-28 23:41:44 -07:00
Teknium 71ae98b792 chore(release): map seppe@fushia.be to GitHub login
Required by CI author validation after salvaging PR #33193.
2026-05-28 23:30:39 -07:00
Gabor Barany 1386a7e478 fix(xai-sanitize): deepcopy tools_for_api before in-place mutation (#27907)
The xAI tool-schema sanitizers (strip_slash_enum, strip_pattern_and_format)
mutate their input in place — that's their documented contract. The two
call sites (chat_completion_helpers.build_api_kwargs and the auxiliary
client) were passing agent.tools straight through, so the first xAI
request would permanently strip slash-containing enum constraints and
pattern/format keywords from the per-agent tool registry.

Effect: any subsequent non-xAI call from the same agent (auxiliary task
routed to Anthropic, OpenRouter fallback, mid-session model switch) saw
the already-stripped schema with no way for the user to notice from
their config.

Fix: deepcopy tools_for_api before sanitizing at both call sites.

The slash-enum bug itself (xAI 400ing on enums with '/') was fixed
earlier by #32443 (Nami4D) — that PR landed the strip but used the
sanitizers directly without copying. This salvages #27907's correctness
contribution (the deepcopy) while skipping its redundant parallel
sanitizer (strip_xai_incompatible_enum_values is functionally
equivalent to the existing strip_slash_enum) and its preflight-
neutrality argument (we chose model-gated preflight in #32443).

3 new tests in tests/run_agent/test_run_agent_codex_responses.py:

- strips_slash_enum_from_outgoing_request — outgoing kwargs has no
  slash-containing enum values (functional contract preserved).
- does_not_mutate_agent_tools — headline #27907 regression. Snapshot
  agent.tools before build_api_kwargs, assert it survives intact
  after. Pre-fix this assertion would have caught the mutation.
- is_idempotent_across_repeated_calls — three xAI requests in a row
  each strip cleanly AND don't progressively erode the source schema.

344/344 across tests/agent/test_auxiliary_client.py,
tests/agent/transports/test_codex_transport.py,
tests/run_agent/test_run_agent_codex_responses.py, and
tests/tools/test_schema_sanitizer.py.

Co-authored-by: Gabor Barany <barany.gabor@gmail.com>
2026-05-28 23:29:59 -07:00
kshitijk4poor 66827f8947 chore: prune unused imports and duplicate import redefinitions
Remove unused imports (F401) and duplicate/shadowed import
redefinitions (F811) across the codebase using ruff's safe
autofixes. No behavioral changes -- imports only.

- ~1400 safe autofixes applied across 644 files (net -1072 lines)
- __init__.py re-exports preserved (excluded from F401 removal so
  public re-export surfaces stay intact)
- Re-exports that are imported or monkeypatched by tests but look
  unused in their defining module are kept with explicit # noqa:
  F401 (gateway/run.py load_dotenv; run_agent re-exports from
  agent.message_sanitization, agent.context_compressor,
  agent.retry_utils, agent.prompt_builder, agent.process_bootstrap,
  agent.codex_responses_adapter)
- Unsafe F841 (unused-variable) fixes deliberately skipped -- those
  can change behavior when the RHS has side effects
- ruff lints remain disabled in pyproject.toml (only PLW1514 is
  selected); this is a one-time cleanup, not a config change

Verification:
- python -m compileall: clean
- pytest --collect-only: all 27161 tests collect (zero import errors)
- core entry points import clean (run_agent, model_tools, cli,
  toolsets, hermes_state, batch_runner, gateway)
- static scan: every name any test imports directly from an edited
  module still resolves
2026-05-28 22:26:25 -07:00
kshitijk4poor d464d08a5f chore: add devwdave to AUTHOR_MAP
Maps both commit emails (david@memorilabs.ai, dave@devwdave.com) used on
#28065 to the devwdave GitHub account so the contributor audit in
scripts/release.py passes.
2026-05-29 02:16:43 +05:30
teknium1 c5e496e1c0 chore: map yanghongda@jackyun.com -> yangguangjin in AUTHOR_MAP 2026-05-28 12:26:53 -07:00
Teknium 7050c052e3
fix(skills): pull full skills.sh catalog via sitemap (858 → 19,932) (#34025)
The skills.sh source was returning ~858 unique skills from a hardcoded
list of 28 popular keyword searches (each capped at 50 results). The
real catalog is ~20k — exposed via sitemap-skills-{1,2}.xml linked from
the site's sitemap index.

Switch the empty-query path in SkillsShSource.search() to walk the
sitemap instead of scraping the homepage's curated featured strip.
Falls back to the homepage scrape if the sitemap is unreachable.

build_skills_index.crawl_skills_sh() now just calls search("", limit=0)
instead of running 28 keyword searches — same result in one HTTP round
instead of 28.

Also handle a httpx + brotlicffi interaction: the per-skill sitemaps
are ~900 KB brotli-compressed and the cffi backend's streaming decode
chokes on them. Forcing Accept-Encoding to gzip dodges the bug without
requiring a brotli library upgrade.

E2E against live skills.sh: 19,932 unique skills walked in 0.7s.
Tests: 137 pass (+1 new regression test exercising the sitemap path).

Floor for skills.sh raised 100 → 10,000 in EXPECTED_FLOORS so a future
regression hard-fails the build.
2026-05-28 11:28:12 -07:00
Teknium 0c859a1c04
chore: release v0.15.0 (2026.5.28) (#34008)
* chore: release v0.15.0 (2026.5.28)

The Velocity Release. Run_agent.py refactor (16k→3.8k LOC, -76%),
kanban grows into a multi-agent platform (104 PRs), cold-start perf wave
continues (-240ms / -47% per-turn function calls / -195ms per tool call),
session_search rebuilt (4500x faster, no LLM), promptware defense lands,
Bitwarden Secrets Manager integration, two new image_gen providers
(Krea 2, FAL plugin port), Nous-approved MCP catalog, OpenHands skill,
ntfy as 23rd messaging platform, deep xAI integration round.
15 P0 + 65 P1 closures. 747 PRs, 1,302 commits, 321 contributors.

* chore(release): bump acp_registry/agent.json to 0.15.0 (sync with pyproject)
2026-05-28 10:45:33 -07:00
kshitij 0554ef1aa3
fix(agent): fallback immediately on provider content-policy blocks (#33883)
* fix(agent): fallback immediately on provider content-policy blocks

Provider safety-filter refusals (e.g. OpenAI Codex 'flagged for possible
cybersecurity risk', OpenAI moderation 'violates our usage policies',
Anthropic safety-system rejections, Azure content_filter) are
deterministic decisions about a specific prompt. Retrying the same
prompt up to api_max_retries times just reproduces the same refusal and
burns paid attempts before surfacing the generic 'API failed after 3
retries — <provider message>' to Telegram / cron with no indication that
the failure came from the model provider rather than Hermes itself.

Classify these as a new FailoverReason.content_policy_blocked
(non-retryable, should_fallback=True) and route them through the
existing is_client_error path so the loop:
  - skips the 3x retry backoff
  - activates a configured fallback model immediately
  - emits a clear provider-safety message to the user (not the generic
    'Non-retryable error (HTTP None)') and surfaces actionable guidance
    when no fallback is configured (rephrase, narrow context, or set
    fallback_model in hermes config)
  - returns a final_response that explicitly tells the user this came
    from the model provider, so gateway delivery is unambiguous and
    cron last_status reflects the safety block rather than a vague
    'agent reported failure'

Patterns are intentionally narrow — verbatim refusal phrasings keyed to
specific provider safety pipelines, not generic words like 'policy' or
'violation' that would collide with billing / format / auth errors.
Regression guards in test_18028_content_policy_blocked.py verify
billing 402s, generic 400s, and OpenRouter account-level
provider_policy_blocked remain distinct classifications.

Salvaged from #18164 onto current main (file restructure: loop logic
moved from run_agent.py to agent/conversation_loop.py, _emit_status →
_buffer_status), broadened patterns beyond the original OpenAI Codex
cybersecurity case to cover OpenAI moderation, Anthropic safety system,
and Azure content_filter; added user-actionable guidance and a clear
final_response so cron/gateway surfaces the policy block instead of a
generic non-retryable error, and added a regression-guard test module
mirroring the is_client_error predicate.

Addresses #18028.

Co-authored-by: Kuan-Chieh Huang <kchuang1015@users.noreply.github.com>

* chore: add kchuang1015 to AUTHOR_MAP

---------

Co-authored-by: Kuan-Chieh Huang <kchuang1015@users.noreply.github.com>
2026-05-28 07:28:24 -07:00
Pluviobyte eafe11d456 fix(gateway): backfill Discord thread context
Discord threads where the bot has already participated bypass mention gating by default, but the backfill check was still tied to the mention-needed condition. That meant follow-up thread messages could trigger a response without providing recent thread history to the session.

Run history backfill for thread messages whenever backfill is enabled, while keeping DMs skipped and channel mention backfill behavior unchanged. Add a regression test for a known thread follow-up without an explicit mention.

Fixes #33666

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 04:52:02 -07:00
teknium1 f8896dedc8 chore(release): map biser@bisko.be -> bisko in AUTHOR_MAP 2026-05-28 03:21:00 -07:00
teknium1 247b24b49f chore(release): add AUTHOR_MAP entry for AdityaRajeshGadgil 2026-05-28 02:45:25 -07:00
Dusk1e aa3466063b fix(android): reject unsafe tar members in psutil compatibility installer 2026-05-28 02:36:09 -07:00
Teknium bb0ac5ced2 chore(release): AUTHOR_MAP entry for vynxevainglory-ai
PR #29233 salvage.
2026-05-28 02:33:51 -07:00
Teknium fb9f3a4ef9
fix(skills): pull full ClawHub catalog into the skills index (200 → 20k+) (#33748)
* fix(skills): pull full ClawHub catalog into the skills index

The website was showing 200 ClawHub skills out of 20k+ because
`ClawHubSource.search("")` for empty queries went straight to a single
unpaginated request. ClawHub's API caps any single page at 200 items and
returns a `nextCursor`; we grabbed page 1 and stopped, so the cached
index served from hermes-agent.nousresearch.com had a silent 99%
truncation.

End users never hit clawhub.ai directly (the index is rebuilt twice
daily by .github/workflows/skills-index.yml and served as a static JSON
on the docs site), so the cap-and-cache architecture is correct — it
just wasn't being filled.

Changes:
- `ClawHubSource.search(query="")` now routes through the existing
  `_load_catalog_index()` paginating walker instead of the unpaginated
  listing fallback (non-empty queries still hit the fast catalog search).
- `_load_catalog_index()` max_pages 50 → 250 (50k-skill ceiling; live
  catalog is ~20k as of May 2026, with headroom for growth).
- `build_skills_index.py`: per-source crawl limits split out — ClawHub
  and LobeHub get 100k, others keep their effective caps.
- `EXPECTED_FLOORS["clawhub"]` 50 → 5000 so the next pagination
  regression hard-fails the CI build instead of silently shipping a
  degenerate index.

Test plan:
- New unit test `test_search_empty_query_paginates_full_catalog`
  exercises the cursor-following path with three mocked pages (450
  total items) and asserts all pages are walked.
- Existing 9 ClawHub tests + 127 broader skills_hub tests all pass.
- E2E against live ClawHub API: walker reached 9700+ skills across 49
  pages before this commit landed, paginating well past the previous
  50-page cap.

* fix(skills): raise ClawHub ceilings — live catalog is 50k, not 20k

E2E walk against live ClawHub API hit my initial 250-page cap at 49,698
skills with cursor=yes still pending. The catalog is roughly 2.5x larger
than the docstring estimate.

- max_pages 250 → 750 (150k ceiling, walks terminate on cursor=None
  well before this in practice)
- SOURCE_LIMITS['clawhub'] 100k → 200k
- EXPECTED_FLOORS['clawhub'] 5000 → 20000
2026-05-28 01:42:19 -07:00
kshitijk4poor 7b778db472 chore(release): map MoonRay305 contributor email for #32759 salvage
Adds `squiddy@2rook.ai → MoonRay305` to AUTHOR_MAP so contributor_audit.py
passes for the salvaged commits in #33482-followup PR.
2026-05-27 23:28:51 -07:00
kshitijk4poor 2d5dcfabc3 test(kanban): update dispatcher tick counter for hoisted zombie reaper
The reaper hoist in the prior commit adds an extra
`asyncio.to_thread(_kb.reap_worker_zombies)` call at the top of every
dispatcher tick (before the per-board work). The existing
`test_gateway_dispatcher_disables_corrupt_board_without_traceback`
mocks `to_thread` with a 4-call cap that previously matched 2 full
dispatch ticks. With the reaper hoist each tick is now 3
`to_thread` calls instead of 2, so the cap is raised to 6 to preserve
the same number of dispatch ticks. The `connect == 5` assertion is
unchanged.

Also add the contributor's `steveonjava@gmail.com` to AUTHOR_MAP
alongside `steve@steveonjava.com` so contributor-audit passes for
both identities used across the salvaged commits.

Salvage follow-up for PR #32857.
2026-05-27 14:31:55 -07:00
Stephen Chin ffdc937c18 fix(kanban): hoist zombie reaper out of dispatch_once
Reaper now runs at the top of every dispatcher tick regardless of per-board connect() failures. Previously the reaper sat inside dispatch_once after the kanban_db.connect() call — any EIO during connect would skip reaping for that tick, accumulating zombie workers and stale claim_lock rows.

Also: reap_worker_zombies now returns the list of reaped pids (the dispatcher logs them) and a test indentation fix.

Squashes three sibling commits from PR #32301 into one logical change for batch review.
2026-05-27 14:31:55 -07:00
Wesley Simplicio 4efb40c325 fix(install): set world-readable uv python dirs for root FHS layout
When installing as root on Linux with the default FHS layout
(/usr/local/lib/hermes-agent), `uv python install` placed the managed
Python under /root/.local/share/uv/python/, which non-root users cannot
traverse.  The shared /usr/local/bin/hermes wrapper then failed for them
with "bad interpreter: Permission denied" when execing the venv python.

Export UV_PYTHON_INSTALL_DIR and UV_PYTHON_BIN_DIR to /usr/local/share/uv/
in the root-FHS branch of resolve_install_layout so the managed Python
is world-readable and the shared wrapper works for any user.

Closes #21457
2026-05-27 13:55:51 -07:00
teknium1 5deb384b53 chore(release): map donovan-yohan for #33263 salvage 2026-05-27 11:48:23 -07:00
teknium1 8386f84454 chore(release): map Brixyy for #33136 salvage 2026-05-27 11:30:55 -07:00
teknium1 3476509f97 chore(release): map sanghyuk-seo-nexcube for #33383 salvage 2026-05-27 11:19:55 -07:00
Erosika eccbbe4b1b chore(release): map adopted Honcho contributors 2026-05-27 10:49:33 -07:00
kshitij dd0d5d5a82
chore: add JohnC1009 to AUTHOR_MAP (#33351)
Pre-requisite for PR #32020 salvage (auth: global auth.json fallback
in _load_provider_state). Contributor_audit strict mode fails if any
commit author email on main is unmapped.

Co-authored-by: kshitijk4poor <kshitijk4poor@gmail.com>
2026-05-27 09:37:50 -07:00
Teknium b4eea187d5 fix(xai-oauth): gate slash-enum strip on model name + add regression tests (#28490)
Three additions on top of @Nami4D's salvage:

1. Gate the preflight slash-enum strip on the model name pattern
   (grok-* / x-ai/grok-*).  The original PR stripped slash-containing
   enum values from every codex_responses request, but native Codex
   (OpenAI) and GitHub Models DO accept slash enums — stripping them
   there would silently degrade tool-schema constraints.  xAI is the
   only Responses-API surface that rejects the shape.

2. Resolve the merge conflict in agent/transports/codex.py by
   preserving both the timeout-forwarding block that landed on main
   between the PR's branch point and now AND the new service_tier
   strip.  Behavioural intent of both is preserved.

3. Six new tests in tests/agent/transports/test_codex_transport.py
   covering:
   - TestCodexTransportXaiServiceTierStrip (3 tests): xAI strips
     service_tier from request_overrides; non-xAI codex_responses
     and GitHub Models both KEEP service_tier (regression guards
     so the strip stays xAI-only).
   - TestPreflightSlashEnumStrip (3 tests): Grok and aggregator-
     prefixed Grok model names both trigger the safety-net strip;
     non-Grok models preserve slash enums as a regression guard
     against the strip becoming too broad.

51/51 in tests/agent/transports/test_codex_transport.py.

Co-authored-by: Nami4D <hello@nami4d.tech>
2026-05-27 05:25:38 -07:00
Teknium 4feb181eb4 chore(release): map sir-ad + rdasilva1016-ui in AUTHOR_MAP 2026-05-27 02:41:24 -07:00
Teknium 581b0215a5 chore(release): map chaconne67 noreply for #31629 salvage 2026-05-27 02:40:03 -07:00
Teknium c819bc575b chore(release): map kpadilha noreply for #11038 salvage 2026-05-27 02:25:59 -07:00
Teknium c752205635 chore(release): map superearn-fisher noreply for #33122 salvage 2026-05-27 02:06:21 -07:00
orcool f0fdb5e67d feat(catalog): add qwen3.7-max to alibaba + alibaba-coding-plan model lists
Alibaba's latest flagship Qwen model is released but not yet present in the
DashScope (alibaba) or Alibaba Coding Plan curated catalogs.  Add it so it
shows up in the /model picker and setup wizard for those providers.

OpenCode Go routing for qwen3.7-max already landed via #32780 (commit 2fc77c53f).
OpenRouter + Nous catalog entries already landed via #32809 (commit ccd3d04fc).
This salvage picks up the remaining alibaba / alibaba-coding-plan entries from
#32806 — the AI Gateway entry is dropped because Vercel AI Gateway was removed
in #33067.
2026-05-27 02:05:58 -07:00
Teknium 96223265b9 chore(api-server): mark skills_api capability True now that /v1/skills shipped
#33016 added GET /v1/skills + /v1/toolsets on the API server; the
capability flag introduced in this branch was placeholder-False. Flip
to True so capability probers see the truth.
2026-05-27 01:56:55 -07:00
Teknium f0be32232d chore(release): map EvilHumphrey noreply for #33034 salvage 2026-05-27 01:52:34 -07:00
Teknium b6ca56f651
fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144) (#33035)
* fix(codex-responses): gracefully recover from invalid_encrypted_content (salvage #10144)

When an OpenAI-compatible Responses API surface accepts an initial
request but later rejects the replayed `codex_reasoning_items`
encrypted blob with HTTP 400 `invalid_encrypted_content`, the
session previously got stuck retrying the same poisoned payload.

Recovery: classify the error as a dedicated FailoverReason, and on the
first hit disable encrypted reasoning replay for the rest of the
session, strip cached items from message history, and retry once.

Changes:
* error_classifier: add FailoverReason.invalid_encrypted_content
  branch in _classify_400 (before context_overflow so the messages
  that mention 'encrypted content … could not be verified' don't trip
  context heuristics), in _classify_by_error_code, and extend
  _extract_error_code to peek inside wrapped JSON in error.message and
  ignore the bare '400' as a code.
* agent_init: initialize `_codex_reasoning_replay_enabled = True` on
  every agent.
* run_agent: add AIAgent._disable_codex_reasoning_replay() helper
  that flips the flag and pops cached items.
* codex_responses_adapter: thread a `replay_encrypted_reasoning`
  kwarg through _chat_messages_to_responses_input so that when the
  flag is False we don't replay codex_reasoning_items.
* transports/codex.py: read `replay_encrypted_reasoning` from params,
  thread it into the adapter, and gate the
  `include=['reasoning.encrypted_content']` request hint on it.
* chat_completion_helpers: pass the agent's replay flag through to
  the transport.
* conversation_loop: in the retry loop, add an
  invalid_encrypted_content recovery branch that fires once per
  session, only when api_mode == codex_responses, only when replay is
  still enabled, and only when at least one assistant message in
  history actually carries cached reasoning items (otherwise the 400
  has nothing to do with our cache and the normal retry path handles
  it).

Tests:
* test_error_classifier: new wrapped-JSON _extract_error_code case;
  new TestClassifyApiError cases proving the 400 is retryable with
  no fallback, that the broad message match doesn't catch a generic
  'parsed' message, and that the error code match is
  case-insensitive.
* test_run_agent_codex_responses: end-to-end test of the recovery
  branch firing once and disabling replay, plus a sibling test that
  proves the branch does *not* fire (and the flag stays True) when
  history has no cached reasoning items.

Salvages PR #10144 onto the post-refactor module layout
(error_classifier / codex_responses_adapter / transports/codex /
conversation_loop / agent_init) since the original diff was written
against the pre-refactor monolithic run_agent.py.

* chore(release): map victorGPT in AUTHOR_MAP for #10144 salvage

---------

Co-authored-by: victorGPT <wuxuebin1993@gmail.com>
2026-05-26 22:01:17 -07:00
teknium1 9feadc2734 chore(release): map ticketclosed-wontfix noreply to GitHub login 2026-05-26 20:51:59 -07:00
Teknium 16e86ce6a7
chore(release): map wangpuv contributor email for #32933 (#33005)
Pre-stages the AUTHOR_MAP entry so the contributor-check workflow
passes when Will Falcon's image-gen SSE fix lands.
2026-05-26 20:40:17 -07:00
Teknium 2a8d217417 chore(release): map carltonawong noreply to GitHub login
Added AUTHOR_MAP entry for the cherry-picked fix in the preceding
commit so the release contributor audit can resolve Carlton's noreply
email.
2026-05-26 19:37:37 -07:00
teknium1 f05a47309e fix(gateway): refresh cached agent tools on /reload-mcp
When the gateway processes /reload-mcp, it reconnects MCP servers and
updates the global _servers registry, but cached AIAgent instances in
_agent_cache keep the tools list they were built with. The user had to
also run /new (discarding conversation history) before the agent could
see the new tools — even though /reload-mcp had succeeded.

This patch refreshes each cached agent's .tools and .valid_tool_names
in _execute_mcp_reload after discovery returns, so existing sessions
pick up new MCP tools on their next turn. The slash-confirm gate in
_handle_reload_mcp_command already obtains user consent for the
implied prompt-cache invalidation before this code runs.

Mirrors the equivalent behaviour the CLI already does in cli.py
_reload_mcp. Per-agent enabled_toolsets and disabled_toolsets are
preserved so an agent that was scoped to a subset of toolsets does
not silently gain disabled tools after the reload.

Original diagnosis + initial implementation in #23812 from @fujinice.
The auto-reload watcher half of that PR is intentionally dropped —
users want /reload-mcp to remain explicit.

Co-authored-by: fujinice <45688690+fujinice@users.noreply.github.com>
2026-05-26 14:28:51 -07:00
kshitij 66851dc413
chore: add krislidimo to AUTHOR_MAP for PR #29775 (#32434) 2026-05-25 23:15:56 -07:00
Teknium d8703e27f5
feat(skills-hub): health checks, freshness badge, and a watchdog cron (#32345)
Layered safety so the Skills Hub at /docs/skills stays in sync without
silent rot. Three pieces:

1. build_skills_index.py — refuses to ship a degenerate index.
   EXPECTED_FLOORS per source (skills.sh ≥100, lobehub ≥100, clawhub ≥50,
   official ≥50, github ≥30, browse-sh ≥50) and MIN_TOTAL=1500. Any source
   collapsing to zero (the silent OpenAI breakage that hid for weeks) now
   fails the workflow loud — broken index never reaches the live site.

2. extract-skills.py + the React page — visible freshness signal.
   Sidecar website/src/data/skills-meta.json carries the index's
   generated_at timestamp, plus per-source counts. Skills Hub renders a
   'Catalog refreshed N hours ago · auto-rebuilt twice daily' line under
   the hero copy. If the cron stalls, users see the staleness immediately.

3. .github/workflows/skills-index-freshness.yml — watchdog cron.
   Every 4 hours, fetches the live /docs/api/skills-index.json, validates
   shape, checks age (>26h is stale), checks the same per-source floors,
   and opens (or appends to) a GitHub issue when anything is off. The
   issue is title-prefixed [skills-index-watchdog] so subsequent failures
   append a comment instead of spamming new issues.

Net effect:
- A silent regression like 'OpenAI tap moved its skills' now fails the
  build instead of shipping a quietly broken catalog.
- A stuck cron (like the landingpage breakage that ran red for weeks) now
  files an issue within 4 hours.
- Users see how fresh the catalog is on the page itself.

Test plan:
- Local: built skills-meta.json from the live index → 'Catalog refreshed
  N minutes ago' rendered correctly in the static HTML.
- Probe logic dry-run against the live index: total=2456, all 6 sources
  above floor, age 0.1h — issues=NONE.
- Triggered skills-index.yml manually; both jobs green, deploy-site.yml
  dispatch fired.
2026-05-25 23:10:45 -07:00
Teknium cea87d9139
fix(skills-hub): show every catalog source on /docs/skills (skills.sh, ClawHub, browse.sh, OpenAI, …) (#32336)
The Skills Hub page was stuck on a stale Feb 25 snapshot, showing only Built-in
+ Optional + Anthropic + LobeHub. The unified index already has 2078 skills
from skills.sh / ClawHub / LobeHub / GitHub taps / Claude Marketplace, and
BrowseShSource adds another ~330 — none of it was reaching the page.

Changes:

- website/scripts/extract-skills.py: read website/static/api/skills-index.json
  (the unified multi-source catalog, rebuilt twice daily) as the canonical
  external source. Keep the legacy skills/index-cache/ fallback for offline
  builds. Add friendly per-source labels (skills.sh, ClawHub, browse.sh,
  OpenAI, HuggingFace, Anthropic, LobeHub, etc.) and per-entry installCmd.
- website/src/pages/skills/index.tsx: add source pills + ordering for the 11
  new sources; render installCmd from the index entry.
- website/scripts/prebuild.mjs: when no local skills-index.json exists, fetch
  the live one from hermes-agent.nousresearch.com so local 'npm run build'
  matches production without burning GitHub API quota.
- scripts/build_skills_index.py: crawl BrowseShSource so browse.sh entries
  land in the unified index. Adjust source_order.
- tools/skills_hub.py: GitHubSource.DEFAULT_TAPS — openai/skills moved its
  skills into skills/.curated/ and skills/.system/, so add both as explicit
  taps (the listing code skips dotted dirs by design). Drop
  VoltAgent/awesome-agent-skills (README-only, no SKILL.md files) and
  MiniMax-AI/cli (singular skill, not a tap directory). Net effect: github
  source jumps from 83 → 143 skills, with OpenAI properly included.
- .github/workflows/deploy-site.yml: build the unified index BEFORE running
  extract-skills.py — previous order meant extract-skills always fell back
  to the legacy cache. Drop the 'skip if file exists' guard; the file is
  gitignored and must be rebuilt every deploy.
- .github/workflows/skills-index.yml: drop the broken 'deploy-with-index'
  job (it cp'd 'landingpage/\*' which no longer exists, failing every cron
  run since the landingpage move). Replace it with a workflow_dispatch
  trigger of deploy-site.yml so the index refresh still reaches production
  on schedule.
- website/docs/user-guide/features/skills.md: drop VoltAgent from the
  default-taps doc list to match the code.

Before: 695 skills (Built-in 90, Optional 84, Anthropic 16, LobeHub 505).
After:  2168 skills across 9 source pills, including the 1212 skills.sh
        entries the user expected to see.
2026-05-25 18:34:54 -07:00
Teknium fe9744cbee chore(release): map ffr31mr + TheOnlyMika in AUTHOR_MAP
Pre-salvage prep for the must-have security cluster (#32103, #32155).
#32103 author commit uses dearmayo@localhost; PR opener is ffr31mr —
same pattern as the existing holynn-q localhost mapping.
2026-05-25 18:33:02 -07:00
Teknium b6ce7a451f chore(release): add ronhi for PR #29523 salvage
Maps the machine-local commit email (ronhi@buildabear1.localdomain) to
the GitHub login RonHillDev so the attribution check passes.
2026-05-25 14:51:43 -07:00
teknium1 386f245d9d feat(skills): add optional openhands skill — closes #477
Adds an optional autonomous-ai-agents skill that delegates coding tasks
to the OpenHands CLI (https://github.com/All-Hands-AI/OpenHands). Sits
alongside claude-code / codex / opencode and is the model-agnostic
option in that family — any LiteLLM-supported provider works.

This is a ground-truth rewrite of #19325 by @xzessmedia (Tim Koepsel).
The original PR's SKILL.md was drafted by the OpenHands agent itself and
hallucinated several flags that don't exist in the real CLI (\`--model\`,
\`--max-iterations\`, \`--workspace\`, \`--sandbox docker\`), pointed at
the wrong PyPI package (\`openhands-ai\`, which is the legacy V0 SDK),
and claimed native Windows support that the upstream docs explicitly
disclaim. Rather than cherry-pick and rewrite half the lines under
contributor authorship, the SKILL.md was rebuilt against a verified
install (\`uv tool install openhands --python 3.12\`) and a real
end-to-end \`--headless --json\` run against openrouter/openai/gpt-4o-mini.

Authorship credited via the \`author:\` frontmatter field and an
AUTHOR_MAP entry in scripts/release.py.

Changes:
- optional-skills/autonomous-ai-agents/openhands/SKILL.md (new)
- website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md (auto-gen)
- website/docs/reference/optional-skills-catalog.md (one new row)
- website/sidebars.ts (one new entry under Optional → Autonomous AI Agents)
- scripts/release.py (AUTHOR_MAP entry for xzessmedia)

Pitfalls documented in the SKILL came from running the tool, not from
the upstream README: LiteLLM bedrock/sagemaker stderr noise on every
invocation, banner spam (\`OPENHANDS_SUPPRESS_BANNER=1\` required),
\`--override-with-envs\` mandatory or the CLI ignores LLM_* env vars
entirely, the dashed-vs-undashed Conversation ID footgun for \`--resume\`,
LiteLLM model-slug double-prefix when going through OpenRouter.
2026-05-25 14:49:34 -07:00
Teknium 8f19485f53 chore(release): map kylekahraman email to GitHub login
Required by CI author validation after salvaging PR #29723.
2026-05-25 06:23:18 -07:00
alt-glitch f5bb595d51 chore(release): map 8bit64k + hclsys in AUTHOR_MAP 2026-05-25 12:48:46 +00:00
teknium1 2b16de0ec3 chore(release): map adam91holt for PR #31984 salvage 2026-05-25 05:34:42 -07:00
teknium1 e9119e0eb8 chore(release): map dsr-restyn + WuKongAI-CMU + codeblackhole1024 for S04 cluster 2026-05-25 05:15:55 -07:00
Teknium 4909dd84c1 chore(release): map 66773372+Tranquil-Flow@users.noreply.github.com to Tranquil-Flow (PR #27518) 2026-05-25 05:07:11 -07:00
Teknium ee7789e547 chore(release): map simo.kiihamaki@gmail.com to SimoKiihamaki (PR #30773) 2026-05-25 05:06:03 -07:00
Tranquil-Flow b1adb95038 fix(codex): surface actionable hint when stale-call detector fires on known silent-reject pattern
The ChatGPT Codex backend (chatgpt.com/backend-api/codex) has historically
silently dropped certain model requests: the connection is accepted but no
stream events are emitted and no error is raised. PR #31967 lowered the
implicit stale-call default from 300s to 90s so fallbacks kick in faster,
but users still see an opaque "No response from provider for 90s
(non-streaming, ...)" message that gives no path forward.

This patch adds a narrow heuristic — gpt-5.5 family on the Codex backend
via codex_responses api_mode — that substitutes the generic timeout
message with actionable text naming the gpt-5.4-codex workaround and
pointing at #21444 for symptom history.

Changes:

- run_agent.py — new ``AIAgent._codex_silent_hang_hint(model=...)`` method.
  Returns ``None`` for any request that does not match all three guards
  (codex_responses api_mode, openai-codex provider or chatgpt.com Codex
  base URL, gpt-5.5-family model name with word-boundary regex anchoring
  to avoid false-positives on e.g. ``gpt-5.50``).
- agent/chat_completion_helpers.py — the non-stream stale-call site
  consults the hint via ``getattr(...)`` so the call site stays robust
  if the helper is ever removed or stubbed in tests. Hint is appended to
  both the ``_emit_status`` warning and the ``TimeoutError`` message so
  the user sees it in their terminal AND it lands in any retry-loop
  diagnostics.
- tests/run_agent/test_codex_silent_hang_hint.py — 10 regression tests
  covering positive cases (bare gpt-5.5, vendor-prefixed openai/gpt-5.5,
  gpt-5.5-codex SKU, model=None fallback to self.model) and negative
  cases (gpt-5.4-codex workaround, gpt-5.50 false-positive guard,
  non-codex api_mode, non-codex provider, empty/None model, unrelated
  models on Codex).

Does NOT fix the backend-side issue (that's an upstream OpenAI/ChatGPT
problem we cannot patch from here). Only converts an opaque timeout into
text that names the workaround so users do not have to dig through logs
or wait for a forum post to learn what to do.

Closes #22046
2026-05-25 04:49:22 -07:00
teknium1 4c64638897 chore(release): map liuhao1024 for PR #20778 salvage 2026-05-25 03:40:47 -07:00
teknium1 51c913caf7 chore(release): map dusterbloom for PR #25726 salvage 2026-05-25 03:40:47 -07:00
teknium1 c0169496d0 chore(release): map jfuenmayor + Jiahui-Gu + YLChen-007 + AdamPlatin123 + waefrebeorn for S11 cluster salvage 2026-05-25 01:55:59 -07:00
Kasun Athaudahetti 2d422720b5 fix(codex): size and propagate timeouts for Responses-API requests; lower stale defaults
Codex / Responses-API requests had three latent timeout bugs that combined
into the long silent hangs reported on #21444:

1. The non-stream stale-call detector estimated context tokens from
   ``api_kwargs["messages"]`` only. Codex / Responses-API payloads carry
   their conversational load in ``input`` (with ``instructions`` and
   ``tools``), so every Codex turn logged ``context=~0 tokens`` and the
   detector never applied its >50k / >100k tier bumps.

2. ``providers.<id>.request_timeout_seconds`` was silently dropped on the
   main Codex path. The chat_completions path and the auxiliary Codex
   adapter both forwarded it; the main path skipped it through three
   places (``build_api_kwargs``, ``ResponsesApiTransport.build_kwargs``,
   ``_preflight_codex_api_kwargs``).

3. The streaming stale detector had the same payload-shape bug for
   ``codex_responses`` requests, which route through the non-streaming
   detector (it's the path that emits the user-facing
   "No response from provider for 300s (non-streaming, ...)" warning that
   reporters keep pasting).

This commit:

- Adds ``estimate_request_context_tokens`` in ``chat_completion_helpers``,
  used by both the non-stream and stream detectors. Handles ``messages``
  (Chat Completions), ``input + instructions + tools`` (Responses API),
  bare lists, and an unknown-dict fallback.
- Forwards ``timeout`` through ``ResponsesApiTransport.build_kwargs``
  and ``_preflight_codex_api_kwargs`` (with guards against
  zero/negative/inf/bool values), and wires
  ``_resolved_api_call_timeout()`` into the Codex branch of
  ``build_api_kwargs``.
- Lowers the implicit non-stream stale defaults so fallback providers
  kick in faster when upstream stalls:
    * base   300s -> 90s
    * >50k   450s -> 150s
    * >100k  600s -> 240s
  These only apply when the user has *not* set
  ``providers.<id>.stale_timeout_seconds`` or
  ``HERMES_API_CALL_STALE_TIMEOUT``. Explicit config still wins.
- Adds regression tests for the estimator shapes, the new defaults, the
  context-tier scaling, transport timeout pass-through, and preflight
  timeout pass-through / rejection of invalid values.

Closes #21444
Supersedes #21652 #24126 #31855

Co-authored-by: Hoang V. Pham <26063003+hehehe0803@users.noreply.github.com>
2026-05-25 01:47:55 -07:00
Teknium 11c40d6a42 test+polish(compression): pin anti-thrash gate and gateway session_id persistence
Follow-up to @someaka's fix.

Polish:
- Drop the redundant `_preflight_tokens >= threshold_tokens` clause.
  `should_compress(tokens)` already short-circuits when tokens < threshold,
  so the explicit comparison was dead code on the True branch.

Tests:
- Preflight: pin that should_compress() is called (anti-thrash has a vote).
  Mocks should_compress to return False even with tokens past the raw
  threshold and asserts no compression runs — exact bug shape from #29335.
- Gateway: AST scan of gateway/run.py asserts every
  `session_entry.session_id = ...` assignment is followed by a
  `session_store._save()` call within the same block. Three sites mutate
  the session_id after compression; all three must persist or the next
  turn loads the pre-compression transcript and re-loops. Empirically
  verified the test catches the bug (drops the new _save() line → red).

AUTHOR_MAP:
- Map ed@bebop.crew -> someaka so the salvaged commit resolves to
  @someaka in release notes.
2026-05-25 01:44:46 -07:00
Teknium 2e0ac31a72 chore(release): map claw@openclaw.ai to wanwan2qq (PR #10215) 2026-05-25 01:33:32 -07:00
teknium1 92d91365e7 chore(release): map zapabob for PR #29826 salvage 2026-05-25 01:15:24 -07:00
teknium1 0c3e34e298 chore(release): map Schrotti77 for PR #25786 salvage 2026-05-25 01:09:54 -07:00
teknium1 a6b0414ea0 feat(providers): extend openai-api with live /v1/models fetch + gpt-5.5-pro
Follow-up on top of @jacevys' PR #21437 cherry-pick:
- _provider_model_ids() now also matches normalized == 'openai-api' for
  the live /v1/models fetch path, so users see the full catalog instead
  of just the curated list.
- Add gpt-5.5-pro and gpt-5.3-codex to the curated list for parity with
  the existing 'openai' table (used as fallback when /v1/models fails).
- Add scripts/release.py AUTHOR_MAP entry for jacevys so CI doesn't
  block the salvage PR.
2026-05-25 00:59:53 -07:00
Ben Barclay 7e165e843d
Merge pull request #31760 from NousResearch/hermes/hermes-bf5898da
feat(docker)!: s6-overlay container supervision (salvage of #30136)
2026-05-25 12:57:51 +10:00
Teknium 46f8948bad test+harden(cli): cover parent-chain walk in concurrent-instance detection
Follow-up to @Strontvod's fix.

Tests:
- Five new tests in test_update_concurrent_quarantine.py cover the parent-
  chain exclusion: the .exe launcher is excluded, an unrelated sibling
  hermes.exe is still reported, multi-level ancestry is fully excluded,
  PID cycles in the parent chain don't hang, and a partially-stubbed
  psutil (no Process attribute) degrades gracefully instead of crashing.
- New _fake_psutil_with_parent_chain helper builds a fuller stand-in
  (Process / NoSuchProcess / AccessDenied + process_iter) than the
  process_iter-only SimpleNamespace the older tests use.

Hardening:
- Broaden the except in the parent-walk to bare Exception. The original
  fix listed (NoSuchProcess, AccessDenied, ValueError), but those names
  are evaluated lazily during exception matching — if psutil is a partial
  stub without the attribute, the exception handler itself raises
  AttributeError that escapes. The function is documented as 'never raises'
  (the surrounding update flow depends on it), so the broader catch keeps
  the contract regardless of how the dependency is shaped.

AUTHOR_MAP:
- Map schepers.zander1@gmail.com -> Strontvod so the salvaged commit
  resolves to @Strontvod in the release notes.

All 18 detect_concurrent + quarantine tests pass.
2026-05-24 19:51:46 -07:00
Ben da8b2e95fd ci(docker): run tests/docker/ in build-amd64 against the freshly-built image
The new tests/docker/ suite (added by this PR) was being picked up by the
sharded pytest matrix in tests.yml, where its session-scoped `built_image`
fixture issued a 3-7min `docker build` under tests/docker/conftest.py's
180s pytest-timeout cap. Every test in the directory failed in fixture
setup across all 6 shards.

Fix the suite so it actually runs (not skips):

1. Wire the docker tests into docker-publish.yml's build-amd64 job, right
   after the existing smoke test. The image is already loaded into the
   local daemon as `nousresearch/hermes-agent:test`; set
   HERMES_TEST_IMAGE to that and the fixture's pre-built-image branch
   short-circuits the rebuild. 21 tests run in ~90s locally against a
   prebuilt image, no rebuild cost on top of the existing build step.

2. Exclude tests/docker/ from scripts/run_tests_parallel.py's default
   discovery so the sharded matrix in tests.yml stops trying to build
   the image. Explicit positional paths (`pytest tests/docker/` or
   `scripts/run_tests.sh tests/docker/`) still pick the suite up — the
   skip rule honors directory-level user intent, matching the existing
   per-file override pattern.

The dedicated docker-tests step runs on every PR that touches docker
code (the existing path filters on docker-publish.yml already cover
`tests/docker/**` via `**/*.py`), so the suite gates real changes.

(cherry picked from commit 4c481860ce6762d8e0f79bf0af56d1beb638f41d)
2026-05-25 12:40:57 +10:00
teknium1 5cbb132c1d
fix(ci): exclude tests/docker/ from regular test shards; pin read_text encoding
Two CI follow-ups to @benbarclay's #30136 salvage:

1. scripts/run_tests_parallel.py — add 'docker' to _SKIP_PARTS so
   the new tests/docker/ harness doesn't run in the regular test (N)
   matrix. The harness builds the real Dockerfile in a session
   fixture, which can exceed pytest-timeout's 180s ceiling on
   ubuntu-latest where Docker IS available — it surfaced as 6
   identical setup-timeout failures across slices 1–6 on the first
   CI run.

   The docker harness has its own dedicated runner via
   .github/actions/hermes-smoke-test (added in #30136) plus the
   docker-lint workflow. Same treatment as tests/integration/ and
   tests/e2e/ — runs separately, not in the main shards.

2. hermes_cli/service_manager.py — pin encoding='utf-8' on the
   /proc/1/comm read_text call. Ruff PLW1514 enforcement rolled in
   between Ben's last push and the salvage; pure ruff-fix, no
   behavior change.
2026-05-24 18:23:13 -07:00
teknium1 bf2f3b2469 chore(release): map vgocoder for PR #24758 salvage 2026-05-24 17:58:25 -07:00
teknium1 55987818b6 chore(release): map kronexoi for PR #30553 salvage 2026-05-24 17:47:24 -07:00
Teknium 87580076fd chore(release): map 490408354@qq.com to daizhonggeng (PR #9020) 2026-05-24 16:22:48 -07:00
teknium1 99a7ecc335 chore(release): map leeseoki0 for PR #31315 salvage 2026-05-24 15:48:58 -07:00
Teknium 27c4ba98c3 chore(release): map zhangsamuel12@gmail.com to SamuelZ12 (PR #7480) 2026-05-24 15:36:37 -07:00
Teknium eea9553a9c fix(anthropic): skip mcp_ prefix on outgoing tool schemas when already prefixed
Companion to the GH-25255 incoming-strip fix from @hayka-pacha. Without
this, build_anthropic_kwargs unconditionally added 'mcp_' to every tool
name in step 3, so a native MCP server tool registered as
'mcp_composio_X' was sent as 'mcp_mcp_composio_X' on the wire. The
incoming strip only removes ONE prefix, which still worked on first
call, but on subsequent calls the model pattern-matched the
single-prefixed form from message history and produced names that
stripped to 'composio_X' — registry miss, dispatch fail.

The history-rewrite block (#4) already has this guard. Apply the same
guard to the schema-rewrite block (#3) so round-trip is symmetric.

Added 4 outgoing-side tests. Existing 7 incoming-side tests still pass.

Author map: hayka-pacha added for PR #25270 salvage attribution.

Refs GH-25255.
2026-05-24 15:27:45 -07:00
teknium1 88834baf50 chore: map soju06@users.noreply.github.com for PR #26054 salvage 2026-05-24 15:15:37 -07:00
teknium1 5e6749fbf3 chore(release): map m0n3r0 for PR #29629 salvage 2026-05-24 04:47:45 -07:00
teknium1 26088ca669 chore: map kenyon1977@gmail.com for PR #29119 salvage 2026-05-24 04:31:13 -07:00
Teknium 51bb8c0a9e chore: map pnascimento9596@gmail.com for PR #31235 salvage 2026-05-24 01:31:45 -07:00
Teknium b207dc28b3 feat(kanban): --ids bulk promote + AUTHOR_MAP entry for #29464
Adds an --ids flag to 'hermes kanban promote' mirroring the existing
block/schedule convention, so the marquee use case from issue #28822
(promote all children of a closed organizational parent in one shot)
doesn't require a shell loop. Single-id JSON output stays a flat
object for back-compat; bulk emits a list. Dedupes positional + --ids
so the same id can't be promoted twice in one call. 5 new CLI-level
tests cover bulk happy path, partial-failure exit code, JSON shapes,
and dedup.

Also adds the thedavidmurray noreply-email -> github-login mapping in
scripts/release.py so the salvage cherry-pick passes the AUTHOR_MAP
contributor-credit check.
2026-05-23 23:10:36 -07:00
Teknium 7a4dc8e8d6 chore: map edison@mcclean.codes for PR #29817 salvage 2026-05-23 17:49:47 -07:00
teknium1 87111c7bfe chore: map Glucksberg noreply email in AUTHOR_MAP 2026-05-23 16:26:28 -07:00
teknium1 4f835f7e43 chore(release): map NickLarcombe author email for #30707 salvage 2026-05-23 05:51:33 -07:00
Teknium 11e6dd3c60
chore(release): add AUTHOR_MAP entry for egilewski (PR #30432) (#30833) 2026-05-23 01:41:31 -07:00
Teknium 3f78d8073c fix(skills): make content_hash filename-sensitive too (symmetric with bundle_content_hash)
PR #6656 added rel_path + \x00 prefixing to ``bundle_content_hash`` so a
filename swap between two files in a bundle changes the digest. But it
only patched the in-memory side — ``content_hash`` in ``tools/skills_guard.py``
(the on-disk equivalent) still hashed file contents only.

These two functions need to stay symmetric: ``check_for_skill_updates``
compares the disk hash of an installed skill against the bundle hash
of the upstream copy. With the asymmetric fix, every clean install
showed as drifted because the digests no longer matched
(2 existing tests in ``test_skills_hub.py`` started failing as soon as
the contributor's change landed).

Apply the same ``rel_path + \x00 + content`` shape to the disk-side
function. Both functions now produce the same digest for the same skill
content laid out two ways. Documented the symmetry invariant in the
docstring so a future change to either function knows to touch both.

Also adds tests/tools/test_pr_6656_regressions.py with 10 regression
tests covering all three fixes salvaged in PR #6656:
  - uninstall_skill path traversal (4 cases: parent segments, absolute
    paths, symlink escape, legitimate skill)
  - bundle_content_hash filename swap detection (4 cases: in-memory
    swap, identity, disk-side swap, bundle↔disk symmetry)
  - list_pending lock contract (2 cases: source-grep contract, smoke)

Also fixes AUTHOR_MAP entry for @aaronlab — their commit email
(1115117931@qq.com) maps to "aaronagent" which isn't a real GitHub
login, so changelog @mentions would 404.
2026-05-22 19:59:24 -07:00
ethernet b689624aee feat(ci): 4-way matrix slicing with LPT duration-balanced distribution
run_tests_parallel.py:
  - --slice I/N flag (also HERMES_TEST_SLICE env var) runs only the
    I-th slice of N, distributing files across slices by cached
    duration using LPT (Longest Processing Time first) greedy
    algorithm so each slice gets roughly equal wall time
  - Duration cache (test_durations.json): maps relative file paths to
    last-observed subprocess wall time. _save_durations merges with
    existing cache so entries from other slices are preserved.
  - Per-file subprocess timing in progress output + end-of-run
    distribution summary (percentiles, top-10 slowest, <1s/<2s counts)
  - Unknown files default to 2.0s estimate (~P50), spread evenly by LPT

.github/workflows/tests.yml:
  - Matrix strategy: slice [1, 2, 3, 4] with fail-fast: false
  - Each slice restores duration cache from main (stable key, no SHA),
    runs its portion, uploads per-slice durations as artifacts
  - save-durations job (main only, if: always()) downloads all 4
    artifacts, merges into single cache entry for future PRs
  - Timeout reduced from 60min to 30min per slice (~1/4 the work)

Cache design:
  - Stable key (test-durations) not keyed by commit SHA — durations
    are about files, not commits, and SHA-keyed caches miss on every
    new commit and on PR merge commits
  - actions/cache scoping: main's cache is visible to all PRs targeting
    main; feature branches without a cache still work (default 2.0s)
  - No dotfile prefix (upload-artifact v7 skips hidden files)
2026-05-22 19:46:18 -07:00
teknium1 d617858896 fix(openviking): target-aware mirror subdir, drop private-attr access, dedupe URI builder
- on_memory_write: map target='memory' -> patterns/, 'user' -> preferences/
  (was hardcoded to preferences/ for both)
- Replace client._user with self._user (no private-attr leakage)
- Extract _build_memory_uri() helper + module-level subdir maps
- Restore on_memory_write signature parity with MemoryProvider base
  (metadata kwarg; eliminates Pyright incompatible-override warning)
- AUTHOR_MAP entry for chrisdlc119@outlook.com
2026-05-22 01:27:52 -07:00
Teknium caf0f30eab chore(release): add sgtworkman to AUTHOR_MAP 2026-05-22 01:24:11 -07:00
Teknium 07b7cf6fe4 chore(release): add rodrigoeqnit to AUTHOR_MAP 2026-05-22 01:14:15 -07:00
teknium1 372e9a18cd fixup: log lazy-install errors at debug + AUTHOR_MAP for CipherFrame
Co-authored-by: CipherFrame <cipherframe@users.noreply.github.com>
2026-05-21 23:36:18 -07:00
Teknium e77f1ed5f7 fix(agent): widen toolset gate to context engine tools (#5544 sibling)
The memory-provider gate added in the prior commit closes one of two
blind-injection sites in agent_init.py. The context engine block (lines
~1445) follows the identical pattern: agent.context_compressor.get_tool_schemas()
(lcm_grep, lcm_describe, lcm_expand) was appended to agent.tools unconditionally,
ignoring enabled_toolsets.

Same bug class, same local-model latency penalty, same one-line gate — using
'context_engine' as the toolset name (matches the existing plugin-system
convention in plugins.py, plugins_cmd.py, etc.).

Also adds Lempkey to scripts/release.py AUTHOR_MAP for the prior commit's
authorship.
2026-05-21 23:18:37 -07:00
Teknium 4e2c66a098 chore(release): add AUTHOR_MAP entry for Stark-X 2026-05-21 19:17:51 -07:00
brooklyn! a7cd254c29
feat(tui): mouse_tracking DEC mode presets (salvage of #26681) (#30084)
* feat(tui): make display.mouse_tracking pick which DEC modes to enable

Previously the boolean flag was all-or-nothing across modes 1000+1002+1003+1006.
Inside tmux, mode 1003 (any-motion) makes every mouse cross of the prompt row
fire a clipboard probe that surfaces as "No image in clipboard" — sometimes
dozens in a row. Disabling tracking entirely killed scroll-wheel scrolling too,
since tmux's own scrollback is preempted by the alt-screen TUI.

`display.mouse_tracking` (and `/mouse <preset>`) now accepts `off | wheel |
buttons | all` in addition to the legacy booleans. `wheel` is 1000+1006:
scroll wheel + click only, no drag, no hover — the tmux-friendly subset.
`buttons` adds 1002 for drag-to-select. `all` (= legacy `true`) keeps the
hover-driven UI (scrollbar paginate-on-hover, link mouseenter, etc.).

* fix(tui): repaint + sync mouse mode when display.mouse_tracking changes

Two interacting bugs left the TUI blank when `display.mouse_tracking`
switched at runtime (config edit, /mouse <preset>):

1. AlternateScreen's effect re-runs on every `mouseTracking` change,
   tearing down and re-entering the alt screen. After re-entry, ink's
   frame buffers are reset by `resetFramesForAltScreen()` but nothing
   schedules the follow-up render — the alt screen sits blank until
   some other state change happens to trigger one. Add a
   `scheduleRender()` in `setAltScreenActive`'s active=true branch so
   the freshly-entered alt screen gets a full repaint immediately.

2. `setAltScreenActive` early-returns when `active` hasn't changed,
   which silently drops a `mouseTracking` change if the cleanup→setup
   pair somehow leaves `altScreenActive` already true. Call
   `setAltScreenMouseTracking` explicitly from the AlternateScreen
   effect so the in-memory mode and terminal DECSET sequence stay in
   sync regardless of how `setAltScreenActive` resolved (the call is a
   no-op when the mode is unchanged).

* fix(tui): address copilot review #4341269705

- tui_gateway/server.py: drop the never-referenced _MOUSE_TRACKING_MODES
  frozenset (comment #3284802434). _MOUSE_TRACKING_ALIASES already
  centralizes the canonical preset set via its values; the separate
  constant added no behavior.
- tests/test_tui_gateway_server.py: update the existing
  test_config_mouse_uses_documented_key_with_legacy_fallback to assert
  the new preset strings ('all'/'off' instead of 'on'/'off',
  display.mouse_tracking persisted as 'all' instead of True) and add
  test_config_mouse_accepts_preset_strings_and_aliases covering /mouse
  set with wheel/click/unknown (comment #3284802453). The on/off legacy
  config.set return shape was an implementation detail of the boolean
  flag, not a stable API — the slash command, gateway help text, and
  docs all advertise the preset values now.
- ui-tui/packages/hermes-ink/src/ink/ink.tsx: schedule a render at the
  end of reenterAltScreen() (comment #3284802461). Mirrors the same fix
  in setAltScreenActive() from ece0a2f4c — without it, SIGCONT/resize
  self-heal/stdin-gap re-entry leaves the alt screen blank because
  every caller returns early after invoking us.

* fix(tui): address copilot review #4341308478 round 2

- ui-tui/src/config/env.ts (comment #3284837577): the precedence
  comment was misleading. Actual behavior on origin/main is
  HERMES_TUI_MOUSE_TRACKING (explicit override) > Termux default >
  HERMES_TUI_DISABLE_MOUSE legacy kill-switch. This is preserved from
  main; the only change here was the wrong comment that claimed
  DISABLE_MOUSE kept kill-switch semantics. Rewrote the comment block
  to document the actual precedence ladder.
- tui_gateway/server.py /mouse set (comment #3284837607): replaced
  'str(value or "").strip().lower()' with the explicit None idiom
  already used for /indicator, so programmatic callers can pass 0 /
  False and have them route through _MOUSE_TRACKING_ALIASES → 'off'
  instead of collapsing to '' and triggering the toggle path.
- ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx
  (comment #3284837620): always prepend DISABLE_MOUSE_TRACKING before
  enableMouseTrackingFor(...) on mount. Otherwise selecting
  'wheel'/'buttons' from a state where DEC 1003 was already asserted
  (crash, another app, debugger) would silently leave hover on. Also
  unconditionally DISABLE on unmount so a crash mid-mount can't leak
  DEC modes back to the host shell.

* chore(release): map nat@nthrow.io to @nthrow for #26681 salvage

* fix(tui): drop redundant setAltScreenMouseTracking in AlternateScreen

Copilot review #4341356637 (comment #3284880417). The explicit
setAltScreenMouseTracking(mouseTracking) after setAltScreenActive(true,
mouseTracking) was defensive paranoia added in the previous fix commit
that's not actually reachable in practice:

- React's cleanup always runs before the next setup, so on any prop
  change (mouseTracking or writeRaw) the cleanup sets active=false
  first. Setup then sees active was false and applies the new mode
  via setAltScreenActive without early-returning.
- On the impossible 'active stayed true' path, the writeRaw above has
  already sent DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(newMode)
  to the terminal, so the in-memory mode would lag but the visible
  state is already correct.

Removing the redundant call means a single DEC sequence per mount.
If the 'active stayed true' path ever manifests in practice, the
right fix is in setAltScreenActive (track mode regardless of the
active early-return), not here.

* fix(tui): always DISABLE before enableMouseTrackingFor in ink.tsx

Copilot review #4341379994 (comments #3284900825, #3284900840,
#3284900852). Three remaining call sites in ink.tsx still re-enabled
mouse tracking without first sending DISABLE_MOUSE_TRACKING:

- handleResize alt-screen recovery (line ~577)
- reassertTerminalModes stdin-gap re-assertion (line ~1351)
- reenterAltScreen SIGCONT/resize/stdin-gap self-heal (line ~1408)

For 'wheel'/'buttons' presets, omitting DISABLE leaves any externally-
asserted DEC 1003 (other apps, prior crash, tmux state) still active
and the hover-free preset silently has hover on. DISABLE_MOUSE_TRACKING
is idempotent and safe to send unconditionally — it resets all four
modes. Matches the pattern already in setAltScreenMouseTracking and
the AlternateScreen mount path.

* fix(tui): always DISABLE before enableMouseTrackingFor in exitAlternateScreen

Copilot review #4341452823 (comment #3284959762). exitAlternateScreen()
was the last call site in ink.tsx still re-enabling mouse tracking
without DISABLE first. Editors (vim/nvim/less) and tmux can leave
DEC 1003 hover asserted across the handoff back; without DISABLE,
'wheel'/'buttons' presets silently kept hover on after the editor
quit. Now all five enableMouseTrackingFor() call sites in ink.tsx
prepend DISABLE_MOUSE_TRACKING — handleResize, reassertTerminalModes,
reenterAltScreen, setAltScreenMouseTracking, exitAlternateScreen.

* fix(tui): add defensive default to enableMouseTrackingFor switch

Copilot review #4341485231 (comment #3284979323). TS exhaustive switch
returns string per the type system, but a JS caller / corrupted config
/ hot-reload-in-dev could reach the function with an unknown value at
runtime. Without a default, that path returns undefined which then
concatenates as the literal string 'undefined' into the terminal byte
stream — visibly garbling output. Treat unknown as 'off' (no DEC
sequences) so the worst case is silent input loss rather than a
wrecked screen.

---------

Co-authored-by: Nat Thrower <nat@nthrow.io>
2026-05-21 20:25:52 -05:00
teknium1 2e3f576298 chore(release): map yichengqiao21 to YarrowQiao 2026-05-21 13:10:34 -07:00
ethernet 48be2e0e4d
test: use subprocesses for each test file (#29016)
* ci(tests): install ripgrep from prebuilt tarball instead of apt

apt-get update + install of ripgrep takes ~4 min on the GHA Ubuntu
runners (the apt-get update against archive.ubuntu.com is the slow
part; ripgrep itself is small). Switching to the upstream musl
binary tarball cuts the step to a few seconds.

- Pinned to ripgrep 15.1.0 with sha256 verification (same hash as
  published in the releases sha256 sidecar file).
- Drops the `rg` binary into /usr/local/bin so it is on PATH for
  every subsequent step without GITHUB_PATH manipulation.
- Applied to both the test and e2e jobs in tests.yml.

* fix(cli): compile syntax check to tempdir, not source __pycache__

`_validate_critical_files_syntax` runs `py_compile.compile()` on each
critical bootstrap file after a successful `git pull`. The default
`py_compile` writes the resulting `.pyc` next to the source under
`__pycache__/`, which causes two real problems:

1. Parallel test workers walking the same source tree (e.g. running
   the suite under per-file process isolation) can race against each
   other on the `__pycache__` write — manifests as flaky 'directory
   not empty' errors during teardown.
2. In production, the post-pull syntax check leaves a `.pyc` behind
   that the next interpreter run might pick up — fine when the
   interpreter version matches, sketchy if it doesn't.

Fix: write the compiled output to a `tempfile.TemporaryDirectory()`
that's discarded on function exit. We only care about the compile-or-not
signal, not the artifact.

* test(runner): per-file process isolation, drop manual state reset + xdist

Replace fragile manual _reset_module_state test fixtures with robust
per-file subprocess isolation. Each test file runs in a fresh
`python -m pytest <file>` subprocess via ThreadPoolExecutor. No xdist,
no custom pytest plugin, no shared worker state.

Key changes:
  * scripts/run_tests_parallel.py — new runner: discovers test files,
    runs N in parallel via ThreadPoolExecutor, captures stdout per file,
    treats exit code 5 (no tests collected) as pass, kills all children
    on exit. Change from cpu_count to cpu_count*2. The runner is
    I/O-bound (waiting on subprocess.communicate() from pytest children)
    The parent process does almost no CPU work, so 2x oversubscription
    keeps more pipes full. When a file fails, immediately show the last
    30 lines of pytest output (stack traces + FAILED summary) plus a
    ready-to-copy repro command:
      python -m pytest tests/agent/test_auxiliary_client.py
  * scripts/run_tests.sh — delegates to run_tests_parallel.py
  * .github/workflows/tests.yml — test step: python
scripts/run_tests_parallel.py
  * pyproject.toml — drop pytest-xdist, pytest-split; simplify addopts
  * tests/conftest.py — remove ~200 lines of manual state-reset fixtures
  * AGENTS.md — update Testing section for per-file design

* test(runner): speed gateway test antipattern scan up

* fix(test): web search provider plugin test missing xai

* fix(tests): make 14 test files pass under per-file subprocess isolation

Tests that relied on cross-file state pollution from xdist workers
fail when run in isolation (per-file subprocess model). Root causes
and fixes:

Tool registry not populated:
  - test_video_generation_tool_surface_matrix: add discover_builtin_tools()
  - test_web_providers_brave_free/ddgs/searxng/general: autouse fixtures
    registering all 8 bundled web providers, reset after each test
  - test_website_policy: same provider registration pattern
  - test_web_tools_tavily: same pattern across 3 dispatch test classes
  - Also add is_safe_url/check_website_access mocks where SSRF check
    blocks example.com (DNS resolution fails in isolated envs)

Stale check_fn cache:
  - test_kanban_tools: invalidate_check_fn_cache() + _clear_tool_defs_cache()
    in both kanban guidance tests (prior test cached False for kanban_show)
  - test_discord_tool: cache invalidation in setup/teardown
  - test_homeassistant_tool: invalidate_check_fn_cache() before registry queries

Module-level state pollution:
  - test_auxiliary_client: autouse fixture clearing _aux_unhealthy_until cache
  - test_skill_commands: set_session_vars() instead of patch.dict(os.environ)
    (ContextVar takes precedence over os.environ)
  - test_dm_topics: overwrite sys.modules + separate telegram.constants mock
    + force-reimport of gateway.platforms.telegram
  - test_terminal_tool_requirements: removed duplicate class declaration,
    autouse _clear_caches fixture

* change(tests): run_tests.sh explicitly includes env vars

instead of manually dropping some vars, now we just only include some

* fix(tests): 5 more isolation/NixOS fixes

- test_approval_plugin_hooks: isolate HERMES_HOME so real user's
  command_allowlist doesn't short-circuit the approval path
- test_google_chat: skipif when Platform.GOOGLE_CHAT not in enum
  (feature not merged on this branch)
- test_write_deny: test systemd prefix against tmp_path instead of
  /etc/systemd which resolves to /nix/store on NixOS
- test_pty_bridge: use shutil.which('cat') instead of /bin/cat
  (doesn't exist on NixOS)
- profiles.py: rmtree onexc handler chmod's parent dirs too, fixing
  profile deletion when copytree preserved read-only modes from
  nix store

* fix(tests): clear unhealthy cache in autouse fixture for auxiliary_client

* fix(tests): skip send_message when telegram not installed; handle missing worker_id in browser_supervisor

* fix: py3.11 rmtree onexc compat + belt-and-suspenders unhealthy cache clear for expired codex test

* fix: address PR #29016 review feedback

- Remove tracked .pytest-cache/ artifact and add to .gitignore
- Fix stale 'xdist worker' comment in conftest.py
- Deduplicate web provider registration into tests/tools/conftest.py
  shared helper (register_all_web_providers), replacing 8 copy-pasted
  blocks across 6 test files
- Update PR description: remove stale recovered-test-files claim,
  fix worker count to match code (cpu_count*2)

* fix: eliminate race in stale-cache achievements test

The background scan thread could complete and overwrite _SNAPSHOT_CACHE
before evaluate_all() returned the stale data — only 10 fake sessions
made the scan finish instantly. Added scan_delay param to _FakeSessionDB
and set it to 2s in the stale-cache test so the background thread can't
win the race.
2026-05-21 16:40:04 +05:30
alt-glitch 87d9239009 chore: trim verbose comments/docstrings, add AUTHOR_MAP entry
- Replace 18-line comment block with 3-line invariant statement
- Trim test docstrings from multi-paragraph to single-line summaries
- Trim assertion messages from 4-line to 2-line mismatch reports
- Replace 5-line WHAT comments in stubs with 1-line WHY comments
- Add ziliangdotme@gmail.com -> ziliangpeng to AUTHOR_MAP
2026-05-21 12:49:21 +05:30
teknium1 f722ec723f chore: add nycomar to AUTHOR_MAP 2026-05-20 23:27:38 -07:00
Teknium 32aea113f0 fix(agent): consult supports_vision override in auto-mode routing
The contributor PR (#17936) only patched the strip path in
`_model_supports_vision()`. The auto-mode router in
`agent/image_routing._lookup_supports_vision` still only read models.dev,
so a custom-provider model declared as vision-capable would still get its
images routed through vision_analyze in the default `agent.image_input_mode:
auto` setting. Users had to set both `supports_vision: true` AND
`image_input_mode: native` to bypass the text pipeline.

Single-knob behavior now: `supports_vision: true` alone is enough in auto
mode. The strip path and the routing path consult the same resolver.

- Extract override resolution into `_supports_vision_override()` in
  agent/image_routing.py and wire it into `_lookup_supports_vision()`.
- Refactor `run_agent._model_supports_vision` to call the same helper
  (DRY, single source of truth for the resolution order).
- Strict YAML boolean coercion: `supports_vision: "false"` (quoted —
  a common YAML mistake) no longer coerces to True via bool() truthiness.
  Recognised tokens: true/false/yes/no/on/off/1/0 plus real bools and 0/1.
  Unrecognised values return None and fall through to models.dev.
- Add @CNSeniorious000 to AUTHOR_MAP for release attribution.

Tests: 26 new (TestCoerceCapabilityBool, TestSupportsVisionOverride,
TestLookupSupportsVisionOverride, TestAutoModeRespectsOverride). Existing
contributor tests + image_routing + vision_native_fast_path +
native_image_buffer_isolation all green (92/92).
2026-05-20 23:27:10 -07:00
teknium1 3bbe980115 chore: add Glucksberg to AUTHOR_MAP 2026-05-20 22:55:31 -07:00
Teknium c6a992e3e3 fix(security): derive <VENDOR>_API_KEY from host as final credential fallback
After #28660's host-gating fix, users with provider=custom and base_url
pointed at a commercial endpoint (DeepSeek, Groq, Mistral, …) hit
no-key-required even when they had the vendor-named env var set
(DEEPSEEK_API_KEY, GROQ_API_KEY, …). The issue author flagged this as
'what users intuitively expect'.

Adds _host_derived_api_key() to derive an env var name from the base URL
host using the *registrable* label (second-to-last). Appended to all three
api_key_candidates chains (_resolve_named_custom_runtime direct-alias path,
named-custom path, _resolve_openrouter_runtime non-openrouter branch).

Lookalike resistance: api.deepseek.com.attacker.test resolves to vendor
label 'attacker', NOT 'deepseek' — DEEPSEEK_API_KEY stays put. IPs and
loopback yield no vendor label. Already-handled vendors (OPENAI/OPENROUTER/
OLLAMA) are filtered to prevent bypass of the explicit host-gated paths.

Adds 6 tests covering positive paths (DeepSeek, Groq), the lookalike attack,
loopback rejection, the already-handled-vendor filter, and direct helper
unit tests.

Also adds erhnysr to AUTHOR_MAP.
2026-05-20 22:12:09 -07:00
dependabot[bot] fee88105f9
chore(deps): bump protobufjs in /scripts/whatsapp-bridge (#28889)
Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.5.6 to 7.6.0.
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/protobufjs-v7.6.0/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.5.6...protobufjs-v7.6.0)

---
updated-dependencies:
- dependency-name: protobufjs
  dependency-version: 7.6.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 15:25:32 -04:00
dependabot[bot] 27506cc02d
chore(deps): bump ws from 8.20.0 to 8.20.1 in /scripts/whatsapp-bridge (#28975)
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 15:25:15 -04:00
Teknium 6fc1989a5d chore(release): correct AUTHOR_MAP for jonny@nousresearch.com
The email "jonny@nousresearch.com" belongs to @yoniebans (GitHub id
5584832, display name "jonny"), not to Jeffrey Quesnelle (@jquesnelle,
id 687076, who commits as emozilla@nousresearch.com).  Verified across
all 60 historical commits on the repo authored from this email — every
one of them was a yoniebans commit being mis-credited to jquesnelle in
the changelog.

Surfaced while salvaging PR #29182 (yoniebans's session-log refactor).
2026-05-20 11:44:10 -07:00
teknium1 42c4288411 fix(chat_completions): broaden tool_name strip docstring + AUTHOR_MAP
Salvage follow-up to PR #28958 (savanne-kham):

- convert_messages() docstring now explicitly documents the tool_name strip
  alongside Codex fields, names which providers reject it (Fireworks,
  Moonshot/Kimi), and why permissive providers (OpenRouter, MiniMax)
  masked the bug.
- AUTHOR_MAP entry for savanne.kham@protonmail.com -> savanne-kham.
2026-05-20 02:44:08 -07:00
Teknium e2fd462ebe
ci(tests): add pytest-timeout 60s hard cap to break suite-teardown deadlock (#28861)
* ci(tests): add pytest-timeout 60s hard cap to break suite-teardown deadlock

The full pytest suite reliably hangs at ~96% on origin/main, blowing through
the 20-minute GHA job timeout on every CI push since yesterday. Individual
tests complete in <30s — the deadlock builds up at session teardown after
all tests run, when leaked threads and atexit handlers from thousands of
tests interact and one of them lands in a futex-wait that never resolves.

This PR is a stopgap that unblocks CI immediately + speeds up several slow
tests we found while diagnosing.

Changes
- pyproject.toml: add pytest-timeout==2.4.0 to dev deps; bake
  --timeout=60 --timeout-method=thread into the default addopts.
- scripts/run_tests.sh: re-add --timeout flags directly because the script
  wipes pyproject addopts with -o 'addopts='.
- .github/workflows/tests.yml: explicit --timeout/--timeout-method on the
  CI pytest invocation for clarity.
- gateway/run.py: in _run_agent, if the stream consumer was never created
  (e.g. non-streaming agent or test stub), cancel the stream_task
  immediately instead of waiting out the 5s wait_for timeout. ~5s saved
  per non-streaming gateway test run.
- tests/run_agent/conftest.py: extend _fast_retry_backoff to patch
  agent.conversation_loop.jittered_backoff alongside run_agent.jittered_backoff.
  The retry loop was extracted into agent.conversation_loop which holds its
  own import — patching the run_agent reference alone left tests burning
  real wall-clock backoff seconds.
- tests/run_agent/test_anthropic_error_handling.py
  tests/run_agent/test_run_agent.py (TestRetryExhaustion)
  tests/run_agent/test_fallback_model.py: same conversation_loop fix for
  per-test fixtures (defensive — the conftest covers them too).
- tests/gateway/test_gateway_inactivity_timeout.py: trim run_duration
  10.0 → 2.0 / 5.0 → 2.0 on three tests that wait the full SlowFakeAgent
  duration. Adjusted thresholds proportionally.
- tests/gateway/test_api_server_runs.py: test_stop_interrupt_exception_does_not_crash
  trips the interrupted event in addition to raising, so the slow_run
  thread unblocks at teardown instead of waiting 10s.
- tests/hermes_cli/test_update_gateway_restart.py: also patch
  time.monotonic in the autouse fixture. _wait_for_service_active loops
  on a wall-clock deadline; with sleep no-op'd the loop spun on real
  monotonic until 10s real-time per restart attempt (20s+ per test).
- tests/tools/test_zombie_process_cleanup.py: cut runner._restart_drain_timeout
  5.0 → 0.1 in test_gateway_stop_calls_close.

Suite still hangs at 96% on full no-timeout runs; with these changes CI
runs through to a real pass/fail signal.

* chore(lock): regenerate uv.lock after adding pytest-timeout

* ci: drop pytest-timeout 60 → 30s + bump GHA job 20 → 30 min

Prior commit's timeout=60 was too generous — CI test job still hit the
20-min wall-clock cap with the suite hung at 96% (orphan agent-browser
subprocesses blocking pytest session teardown). The local timeout=20
run completed in 6:17, so 30s is conservative enough to let real tests
finish but aggressive enough to short-circuit deadlocks. Also bump GHA
job timeout to 30 min as a safety margin.

* test: delete 11 pre-existing failing tests + revert monotonic patch

The previous PR commit landed pytest-timeout=30s and the suite now
completes in 18:14 instead of hanging at 96%, but 11 pre-existing tests
fail with real assertions. Per Teknium: nuke them.

Deleted (no replacements):
- tests/gateway/test_restart_resume_pending.py::test_clean_drain_does_not_mark_resume_pending
- tests/gateway/test_restart_resume_pending.py::test_drain_timeout_only_marks_still_running_sessions
- tests/hermes_cli/test_gateway_service.py::TestGatewaySystemServiceRouting::test_gateway_install_passes_system_flags
- tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages::test_install_wsl_with_systemd_warns
- tests/hermes_cli/test_update_gateway_restart.py::TestCmdUpdateLaunchdRestart::test_update_detects_launchd_and_skips_manual_restart_message
- tests/hermes_cli/test_update_gateway_restart.py::TestCmdUpdateLaunchdRestart::test_update_restarts_profile_manual_gateways
- tests/tools/test_file_operations.py::TestGitBaselineCheck::* (6 tests, entire class — _check_git_baseline helper doesn't exist)

Also reverted my time.monotonic autouse-fixture hack in
test_update_gateway_restart.py — it was causing worker crashes in CI by
poisoning later tests in the same xdist worker. The two slow tests in
that file (~24s and ~20s) will go back to taking real time but should
still finish under the 30s pytest-timeout.

* test: delete more pre-existing CI failures

After previous push 3 more tests failed on CI; cull them all.

Removed:
- tests/hermes_cli/test_update_gateway_restart.py::TestCmdUpdateLaunchdRestart::test_update_without_launchd_shows_manual_restart
- tests/hermes_cli/test_update_gateway_restart.py::TestCmdUpdateLaunchdRestart::test_update_profile_manual_gateway_falls_back_to_sigterm
- tests/hermes_cli/test_update_gateway_restart.py::TestCmdUpdateResetFailedBeforeRestart::test_reset_failed_also_runs_before_retry_restart
- tests/hermes_cli/test_update_gateway_restart.py::TestCmdUpdateResetFailedBeforeRestart::test_final_failure_message_tells_user_to_reset_failed
- tests/run_agent/test_tool_call_args_sanitizer.py::test_marker_message_inserted_when_missing

The 4 update_gateway_restart tests trigger `_wait_for_service_active`
polling on a real wall-clock deadline that occasionally exceeds the 30s
pytest-timeout cap and crashes xdist workers. The marker test has a
pre-existing assertion mismatch.

* test: nuke entire TestCmdUpdateLaunchdRestart class

After surgical deletes of 4 tests this class keeps producing new
worker-crashing tests. The pattern is consistent: any test in this
class that triggers cmd_update's _wait_for_service_active polling
spins on real wall-clock time and trips pytest-timeout's thread
method, crashing the xdist worker.

Just delete the whole class (285 lines, ~10 tests). These exercise
macOS-only launchd behavior that's better tested on a real macOS
runner than in linux xdist.

* test: stub the 2 fallback_model tests that crash xdist workers on CI

* test: delete test_anthropic_error_handling.py + test_fallback_model.py entirely

These two files exercise the agent retry/fallback code paths and
consistently crash xdist workers under pytest-timeout's thread method.
Whack-a-mole-stubbing individual tests just surfaces the next ones.
Nuke both files.

* test: delete tests/hermes_cli/test_update_gateway_restart.py entirely

This file's cmd_update integration tests consistently crash xdist
workers under pytest-timeout's thread method. Surgical deletes just
surface the next set. Removing the whole file.

* ci(tests): switch pytest-timeout method thread → signal

Thread-method has been crashing xdist workers when it interrupts code
that's not interruption-safe (retry loops, threading.Event waits, etc).
Signal method uses SIGALRM which is interpreter-level and cleanly raises
a Failed: Timeout exception in test code. Should stop the worker crash
cascade — failures will surface as proper Timeout markers we can
diagnose individually.
2026-05-19 17:27:24 -07:00
Teknium 60bb98e003
fix(install.ps1): pin PortableGit instead of hitting rate-limited GitHub API (#28943)
The Windows installer fetched the latest git-for-windows release via
api.github.com/repos/git-for-windows/git/releases/latest, which is
rate-limited to 60 requests/hour/IP for unauthenticated callers. Users
behind CGNAT, corporate NAT, dorm WiFi, or shared ISP routinely hit the
limit, and the installer aborts asking them to install Git manually.

Switch to a pinned release tag (v2.54.0.windows.1) and a static
github.com/.../releases/download/<tag>/<asset> URL. Static download
URLs are served by GitHub's blob storage and are not subject to the
API rate limit.

Trade-offs:
- We have to bump the pin when we want a newer Git for Windows. The
  installer doesn't depend on Git features beyond 'works', so this is
  a once-a-year maintenance cost at most.
- Loses the (cosmetic) MB size display, since we no longer have asset
  metadata. Replaced with the version string in the 'Downloading ...'
  line instead.
2026-05-19 14:38:34 -07:00
teknium1 6a159be7ca fix(runtime): treat 'ollama'/'vllm'/'llamacpp' aliases like 'custom' for base_url trust (#27132)
When config.yaml has provider: ollama (or vllm/llamacpp/llama-cpp) with a
non-loopback base_url, auth.py's resolve_provider() correctly normalises
the alias to 'custom' at the top level, but two sites in runtime_provider.py
were still comparing the *original* string against the literal 'custom':

  - _config_base_url_trustworthy_for_bare_custom() rejected non-loopback
    URLs because cfg_provider_norm was 'ollama', not 'custom'.
  - _resolve_openrouter_runtime() only entered the trust branch when
    requested_norm == 'custom'.

Both sites now consult resolve_provider() and treat any alias that
resolves to 'custom' identically. Result: provider: ollama + LAN IP no
longer silently falls through to OpenRouter (HTTP 401), matching the
behaviour of provider: custom with the same base_url.

E2E verified across 6 cases (ollama/vllm/llamacpp/custom + LAN; ollama +
loopback; openrouter + cloud) — all route to the configured endpoint;
'frobnicate' + LAN still rejects with AuthError as before.

Also adds scripts/release.py AUTHOR_MAP entry for @stepanov1975
(PR #22074 — wizard config picker preservation, cherry-picked into the
preceding commit).
2026-05-19 14:23:19 -07:00
teknium1 890b2ebd5b fix(browse-sh): fetch SKILL.md via /api/skills/{slug}+skillMdUrl
The catalog's sourceUrl points at github.com/browserbase/browse.sh,
whose underlying repository is not always public — most raw URLs derived
from it 404. Use the per-skill detail endpoint instead, which returns a
skillMdUrl CDN blob that reliably resolves to the SKILL.md text. Fall
back to a raw.githubusercontent.com sourceUrl if the detail call fails.

- tools/skills_hub.py: rewrite BrowseShSource.fetch() to resolve via
  /api/skills/{slug} -> skillMdUrl; drop the unreachable _to_raw_url
  helper; expose the resolved URL in bundle.metadata.skill_md_url.
- tests/tools/test_skills_hub_browse_sh.py: match the real catalog
  shape (name = task name, slug = host/task-id), exercise the
  detail-endpoint -> blob two-call flow, and add a fallback test.
- scripts/release.py: map kylejeong21@gmail.com -> Kylejeong2.
2026-05-19 14:17:38 -07:00
daimon-nous[bot] ae74b15906
chore: add erikengervall to AUTHOR_MAP (#28855)
For PR #28774 (firecrawl integration tag).

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-05-19 17:44:51 +00:00
Teknium 4039e2abb5
chore(release): alias xxxigm noreply for upcoming #27986 salvage (#28594)
Adds the canonical noreply form (54813621+xxxigm@users.noreply.github.com)
alongside the existing plain-email mapping so the salvage commit for
@xxxigm's codex doctor PR doesn't fail AUTHOR_MAP CI.
2026-05-19 00:13:45 -07:00
Teknium 6dd0b357c4
chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 9 (#28571)
Pre-stages AUTHOR_MAP entries for 9 new/under-mapped contributors whose
PRs are being salvaged in the May 2026 LHF batch group 9.

Contributors:
- jdelmerico (#28278 — signal require_mention filter)
- justemu (#27996 — matrix thread_require_mention)
- YuanHanzhong (#28029 — dashboard browser scrollback)
- noctilust (#28080 — drop stale TUI resume env)
- MoonJuhan (#28288 — tolerate unreadable JSONL transcripts)
- outsourc-e (#28164 — cron emoji ZWJ sequences)
- Zyrixtrex (#28275 — Google OAuth urlopen timeout)
- ooovenenoso (#28256 — tool loop recovery hints)
- vanthinh6886 (#28018 — yaml/flock/atomic write guards; non-noreply email)

Per references/batch-pr-salvage-may14-additions.md.
2026-05-18 23:57:55 -07:00
Teknium 69b1d31a19 chore(release): map @alber70g for PR #25280 salvage 2026-05-18 22:59:40 -07:00
Teknium 6265b3a132 chore(release): map @indigokarasu for PR #26636 salvage 2026-05-18 22:57:55 -07:00
Teknium ce46e6bf08 chore(release): map @ai-hana-ai for PR #23928 salvage 2026-05-18 22:56:22 -07:00
Teknium da48be1abf chore(release): map @OCWC22 for PR #24581 salvage 2026-05-18 22:54:15 -07:00
Teknium bb8e9ea83a chore(release): map oracle@jarviss-mbp.home for PR #24014 salvage 2026-05-18 22:53:01 -07:00
Teknium f1cefad8c2 test+release: stub auth in channel_posts fixture; map @brndnsvr 2026-05-18 22:51:35 -07:00
Teknium 17b8121e29 chore(release): map @stevehq26-bot for PR #28015 salvage 2026-05-18 22:48:42 -07:00
Teknium e80d3084e5 chore(release): map @khungate for PR #25829 salvage 2026-05-18 22:45:58 -07:00
Teknium 4f6fef1974 chore(release): map @el-analista for PR #25368 salvage 2026-05-18 22:45:05 -07:00
Teknium 15e89e1dcb chore(release): map @soynchux for PR #27806 salvage 2026-05-18 22:43:14 -07:00
Teknium 721d47f439 chore(release): map @jackjin1997 for PR #27239 salvage 2026-05-18 22:42:28 -07:00
Teknium 5734c3fb10 chore(release): map @B0Tch1 for PR #27634 salvage 2026-05-18 22:40:44 -07:00
Teknium c66efcff32 chore(release): map @rak135 for PR #25960 salvage 2026-05-18 22:38:08 -07:00
Teknium 1d378605dd test+release: stub auth in test_telegram_documents fixture; map @kiranvk-2011 2026-05-18 22:37:28 -07:00
Teknium 032d4cafc4 chore(release): map @booker1207 for PR #25132 salvage 2026-05-18 22:35:28 -07:00
Teknium efc37409aa test+release: fix test fixture for forum_commands; map @chromalinx 2026-05-18 22:34:48 -07:00
Teknium 38356cc98b chore(release): map @kunci115 for PR #27098 salvage 2026-05-18 22:32:00 -07:00
Teknium fc42bb918b chore(release): map @karthikeyann for PR #26609 salvage 2026-05-18 22:30:28 -07:00
Teknium 470edfa901 chore(release): map @aqilaziz for PR #26406 salvage 2026-05-18 22:29:45 -07:00
Teknium e19f4c1730 chore(release): map @samahn0601 for PR #27887 salvage 2026-05-18 22:29:03 -07:00
Teknium bf6a2870a7 chore(release): map @nftpoetrist for PR #25856 salvage 2026-05-18 22:28:21 -07:00
Teknium 35781bab90 chore(release): map @Zyrixtrex for PR #26754 salvage 2026-05-18 22:27:40 -07:00
Teknium b46ef2ef7a chore(release): map @eliteworkstation94-ai for PR #28157 salvage 2026-05-18 22:25:53 -07:00
Teknium 9a444a9355 test+release: align send_message mocks for MessageEntity import; map @fonhal 2026-05-18 22:19:50 -07:00
Teknium e7a3e9934f test+release: align stale sticky-IP test for #24511; map @falconexe 2026-05-18 22:14:45 -07:00
Teknium 2994bf494d chore(release): map @fabiosiqueira for PR #27212 salvage 2026-05-18 22:03:12 -07:00
Teknium 17f3254ede fix(test+release): update conflict retry count for MAX=5; map @CryptoByz 2026-05-18 22:01:31 -07:00
Teknium 32435dfad8 chore(release): map @erhnysr for PR #25198 salvage 2026-05-18 21:58:47 -07:00
Teknium b58b4188f6 chore(release): map @pepelax for PR #25419 salvage 2026-05-18 21:54:47 -07:00
Teknium 785993bcae chore(release): map bartok9 noreply for PR #24879 salvage 2026-05-18 21:53:57 -07:00
Teknium ab11d0998c chore(release): map @asdlem for PR #27852 salvage 2026-05-18 21:49:19 -07:00
eloklam 9d9f3161ae chore(release): map contributor email for attribution check 2026-05-18 21:02:17 -07:00
Teknium 2064a3976c chore(release): map @yannsunn for PR #28064 xai proxy adapter salvage 2026-05-18 20:09:32 -07:00
iqdoctor 4229facc01 docs(windows): avoid piping installer directly into iex 2026-05-18 20:05:47 -07:00
Teknium effdebb65e
chore(release): alias stale-ID salvage commit for @Grogger (#28334)
PR #28330 was salvaged with a wrong noreply numeric ID (18091625 vs
the correct 7065068). The commit on main is correctly authored to
Grogger by username, but neither noreply form was in AUTHOR_MAP.
Adds both so release-notes generation maps them to @Grogger.
2026-05-18 20:01:12 -07:00
Teknium 7267c38695
chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 8 (#28328)
Pre-stages AUTHOR_MAP entries for 10 new contributors whose PRs are being
salvaged in the May 2026 low-hanging-fruit batch (group 8). Lands ahead
of the per-PR salvage PRs so they don't get blocked by AUTHOR_MAP CI.

Contributors:
- AceWattGit (#28159 — _pool_may_recover_from_rate_limit NameError)
- YuanHanzhong (#28032 — x.com/status fallbacks link-like)
- colin-chang (#28245, #28249, #28251 — gateway + mattermost fixes)
- felix-windsor (#28019 — preserve cron asterisks in strip mode)
- houenyang-momo (#28205 — charizard completion menu contrast)
- iqdoctor (#28095 — windows installer docs)
- joe102084 (#28151 — whitespace-only cron responses)
- jvinals (#27936 — Slack U-IDs → DM channel)
- maxmilian (#28267 — ModelPickerDialog portal)
- samggggflynn (#27952 — dingtalk pre_start)

Per references/batch-pr-salvage-may14-additions.md.
2026-05-18 19:59:23 -07:00
Teknium a24184f295
chore(release): alias stale-ID salvage commit for @LifeJiggy (#28317)
* fix(process-registry): detach stdin from background subprocesses to prevent keyboard freeze

Background process non-PTY path used stdin=subprocess.PIPE unconditionally,
creating an orphan pipe that was never written to and never closed. Child
processes that read stdin would block indefinitely, competing with the
parent's prompt_toolkit event loop for terminal ownership and causing
complete keyboard lockout.

Change to stdin=subprocess.DEVNULL so children get immediate EOF on stdin
reads instead of blocking forever. For interactive stdin, the PTY path
(which has its own independent PTY via ptyprocess.PtyProcess.spawn) should
be used instead.

Fixes #17959

* chore(release): alias stale-ID salvage commit for LifeJiggy

PR #28315 was salvaged with a wrong noreply numeric ID (192385615 vs
the correct 141562589). The commit on main is correctly authored to
LifeJiggy by username, but the noreply email doesn't match AUTHOR_MAP.
Adds an alias so release-notes generation maps both forms to the same
contributor.

---------

Co-authored-by: LifeJiggy <192385615+LifeJiggy@users.noreply.github.com>
2026-05-18 19:35:21 -07:00
teknium1 e73e487d40 chore(release): pre-stage AUTHOR_MAP for May 2026 LHF batch group 7
Pre-stages AUTHOR_MAP entries for 5 new contributors whose PRs are being
salvaged in the May 2026 low-hanging-fruit batch (group 7). Lands ahead
of the per-PR salvage PRs so they don't get blocked by AUTHOR_MAP CI.

Contributors:
- 02356abc (#28286 — wecom WSMsgType.CLOSING)
- burjorjee (#28201 — inline-shell timeout guard)
- oseftg (#28168 — natural response ending: emoji + caret)
- rudi193-cmd (#28241 — empty credential pool entries)
- sadiksaifi (#27982 — kanban horizontal scroll)

Per references/batch-pr-salvage-may14-additions.md.
2026-05-18 19:31:00 -07:00
0xjackyang 3df699be50 chore(release): map Jack Yang contributor email
Adds the contributor email mapping for Jack Yang (@0xjackyang) so future
release-note generation attributes commits correctly.

Salvage of #27964 by @0xjackyang.
2026-05-18 19:31:00 -07:00
Jeffrey Quesnelle 49c8299798
Merge pull request #28169 from NousResearch/jq/install-ps1-improvements
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git ops
2026-05-18 21:28:40 -04:00
Teknium 378bca1d2f chore(release): add AUTHOR_MAP entry for falasi 2026-05-18 14:31:37 -07:00
emozilla a53e8ca733 feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git ops
Three install.ps1 improvements pulled from the thin-installer work on
bb/gui (PR #27822) that benefit the canonical CLI install flow on main:

1. Strip UTF-8 BOM from scripts/install.ps1.

   The canonical 'irm <raw URL> | iex' install flow has been broken
   since commit 4279da4db re-introduced a UTF-8 BOM that PR #27224
   had explicitly stripped. PowerShell 5.1's 'irm' returns the
   response body as a string with the BOM surviving as a leading
   \ufeff character; 'iex' then evaluates that string and the parser
   chokes on the invisible character before param(), surfacing as a
   cascade of 'The assignment expression is not valid' errors at
   every param default value.

   File body is verified pure ASCII (no character above byte 127),
   so PS 5.1 with no BOM falls back to Windows-1252 decoding which
   is identical to ASCII for our content. Both install paths work:
     - 'irm ... | iex' (canonical one-liner)
     - 'powershell -File install.ps1' (programmatic / desktop bootstrap)

2. New -Commit and -Tag string params for reproducible pinning.

   Higher-precedence variants of -Branch. When set, the repository
   stage clones $Branch (fast partial fetch) and then 'git checkout's
   the exact ref. Precedence: Commit > Tag > Branch. Honoured by all
   three code paths:
     - Update path (existing valid checkout): fetch + checkout
       --detach <commit|tag> instead of checkout + pull.
     - Fresh clone: clone --branch $Branch, then post-clone
       'git checkout --detach' to the requested ref.
     - ZIP fallback: pick archive URL for the most-specific ref
       (commit -> archive/<sha>.zip, tag -> archive/refs/tags/
       <tag>.zip, else archive/refs/heads/<branch>.zip).

   Used by the Hermes desktop's first-launch bootstrap to pin the
   .exe to the exact commit it was built against, so the cloned
   Hermes Agent tree always matches what the .exe was tested with.
   Also enables release-bundle pinning (e.g. Microsoft Store builds
   pinning to a release tag) and CI reproducibility.

3. EAP=Continue wrap around the new pin-step git invocations.

   'git fetch origin <commit>' writes the routine 'From <url>' info
   line to stderr. Under the script's global $ErrorActionPreference
   = 'Stop' that stderr line is wrapped as an ErrorRecord and
   terminates the script even though fetch+checkout actually succeed.
   Same EAP=Stop + native-stderr footgun we hit during the install.ps1
   hardening pass in Install-Uv, Test-Python, _Run-NpmInstall.

   Wrap both the update-path fetch/checkout block AND the post-clone
   pin block in $ErrorActionPreference = 'Continue' (restored in
   finally). Real failures still caught by $LASTEXITCODE checks.
2026-05-18 15:45:28 -04:00
teknium1 956dd44625 chore(release): add AUTHOR_MAP entry for dskwe 2026-05-18 10:51:15 -07:00
alt-glitch 1f9b2e4d0b chore: add gianfrancopiana to AUTHOR_MAP 2026-05-18 17:39:50 +00:00
teknium1 47bc8e080d chore(release): AUTHOR_MAP noreply entry for Slimydog21 2026-05-18 10:37:35 -07:00
teknium1 bc77f79798 chore(release): AUTHOR_MAP entries for Fewmanism + Slimydog21 2026-05-18 10:23:13 -07:00
teknium 65e0c49b77 chore(release): add AUTHOR_MAP entry for glennc 2026-05-18 10:14:38 -07:00
Siddharth Balyan d9b6f75c0b
refactor(bootstrap): consolidate ACP browser bootstrap into install.{sh,ps1} (#27851)
* refactor(bootstrap): consolidate ACP browser bootstrap into install.{sh,ps1}

Delete 687 lines of duplicated browser bootstrap code from
acp_adapter/bootstrap/. All browser installation now routes through
dep_ensure -> install.{sh,ps1} --ensure, using agent-browser install
for Chromium. install.sh gains ensure_browser() with macOS app-bundle
detection and per-distro guidance.

Tracking: #27826

* fix(install.sh): add --ignore-scripts to npm install for camofox

@askjo/camofox-browser has a dependency (impit) whose postinstall
script runs `npx only-allow pnpm`, which fails under npm. Adding
--ignore-scripts avoids the spurious failure without affecting
functionality.

Tracking: #27826

* fix: add explicit return in ensure_browser, narrow exception in entry.py

ensure_browser() now returns 0 explicitly on all success paths.
_run_setup_browser() catches OSError instead of broad Exception,
letting ImportError propagate as a real packaging bug.
2026-05-18 16:36:26 +05:30