Commit Graph

8578 Commits

Author SHA1 Message Date
teknium1
7d2d634708 fix(desktop): renderer heap ceiling and desktop.electron_flags reach packaged launches (#77311)
`desktop.electron_flags` was appended to argv only by the `hermes desktop`
launcher, so a packaged app started from its Start-menu / .desktop entry
never saw it, and nothing set `--js-flags` at all — three Windows reporters
on #77311 confirmed `jsflags=NO` on the renderer command line and OOM
crash-loops with 15 GB of free RAM. Chromium copies `js-flags` to renderer
processes only from the browser's pre-`ready` command line, so main.ts now
reads the two launch keys out of config.yaml itself (no YAML dependency in
the main bundle: a deliberately narrow subset reader) and applies them via
app.commandLine before ready.

New typed knob `desktop.renderer_max_old_space_mb` (default 0 = Chromium
default) becomes `--js-flags=--max-old-space-size=N`, MERGED with any
existing js-flags (appendSwitch replaces, so a naive apply would drop one).
The planner is pure (`electron/renderer-heap-flags.ts`) and vitest-covered.
2026-09-20 20:50:54 -07:00
teknium1
10c273813d feat(plugin-catalog): screenshots and readme entry fields for the plugin pages
Two optional, submitter-controlled fields on a catalog entry feed the
entry's own page at /docs/plugins/<name>:

- `screenshots:` — up to 6 https URLs on GitHub hosts (same host rule as
  `image`, so the site never fetches from third-party hosts and a raw URL
  pinned to the sha is as immutable as the code).
- `readme: true` — the docs build renders the README from the PINNED
  commit (raw.githubusercontent.com / gitlab.com raw at <sha>), never live
  content, so what a user reads is what the reviewer read.

Validator rejects malformed values (admission), the loader parses and
drops off-host screenshots with a warning (client), and the extractor
emits `screenshots`, `readme`, `readmeUrl` and a `maintainerSlug` for the
author pages. Tests on all three.
2026-09-20 20:43:13 -07:00
teknium1
2ca53fc386 refactor(cli): move cli.py module-level helper clusters into topical siblings; cli.py lands under 2,000 lines (#116911)
Mechanical, behaviour-neutral extraction along the existing hermes_cli/cli_*.py
pattern. Six clusters of module-level helpers leave cli.py:

- cli_config_load.py     prefill messages, reasoning/service-tier parsing, terminal
                         env mirroring, CLI defaults + YAML merge, logging bootstrap
- cli_render.py          reasoning-tag stripping, ANSI/skin colours, light-mode
                         detection, markdown/final rendering, output-history
                         recording, _cprint, ChatConsole, compact banner, panel wrap
- cli_terminal_input.py  file drops/attachments, bracketed-paste patch, extended
                         Enter keys, CPR guards, TUI input height, query images
- cli_shutdown.py        process session-id sync, exit watchdog, cleanup steps,
                         session-finalize notifications, one-shot finalize
- cli_single_query.py    kanban goal loops, exit-code mapping, quiet -q runner,
                         image routing, signal handlers, single-query mode
- cli_auto_maintenance.py state-db / checkpoint startup maintenance

Every moved body is AST-identical to the base copy modulo two mechanical
edits: cli-level names are late-bound with a call-time `from cli import ...`
(the cli_init_mixin pattern) and mutable cli state is read through `_cli().NAME`
(the gateway_service_unit `_gw()` pattern), so every monkeypatch seam on the
`cli` facade still intercepts moved code and no read snapshots stale state.
Mutable module state and every `global`-writing function stay in cli.py; the
facade re-exports the moved names in one import block.

test_bracketed_paste_timeout AST-loads the paste helper from its new home.
cli.py 3959 -> 1820 lines.
2026-09-20 20:42:17 -07:00
finn763
eaa5523f11 fix(local-runtime): cap resident models by the hardware budget, not a count
Residency was bounded by `local_runtime.models_max` alone, so a second model was admitted
against an already-full card. On Windows/WDDM that over-commit is not refused: the
allocation is paged to host memory and the child decodes at about a third of its speed for
the rest of its life — no error, no UI hint, and ejecting the incumbent afterwards does not
repair it (only a clean reload does).

The router now gets a cap priced from the same capacity budget the presets are priced
against: the count rises above one only while the largest staged model still fits twice, so
any pair of staged models is inside the card by construction and llama.cpp evicts its LRU
before an incoming child allocates. `models_max` stays a ceiling (a user's smaller number is
honoured) and an unpriceable budget or model keeps today's count.

Refs #116078

(cherry picked from commit d7abad7984d16d23ab2442b92d623b2270776586)
2026-09-20 20:42:05 -07:00
teknium1
e77c516e73 fix(models): DeepSeek picker drops retired ids and labels V4.1 Flash (#117516, salvage #117525)
Native `deepseek` is curated-only: `deepseek-flash` / `deepseek-v4-pro`, in that
order. models.dev still indexes the retired `deepseek-v4-flash*` ids, and with
`deepseek` in `_MODELS_DEV_PREFERRED` the registry union re-added them
registry-first whenever the live /models fetch was unavailable. The Desktop
label for `deepseek-flash` now reads "DeepSeek V4.1 Flash".

Trim of the contributor diff: the deepseek-specific branch inside
`_merge_with_models_dev` was unreachable once deepseek left the preferred set,
so it is dropped; the test drives `provider_model_ids("deepseek")` instead of
the helper.
2026-09-20 20:41:54 -07:00
Forkbert
a839530cd8 fix(models): clean up DeepSeek picker catalog
(cherry picked from commit f0b854bd067573e33f6ca8e1c5f68b184f4fef90)
2026-09-20 20:41:54 -07:00
teknium1
e7e1928174 fix(update): the invoking profile's launchd restart is claimed only on a pid that changed
`hermes update` on macOS printed "✓ Restarted ai.hermes.gateway" for the
profile running the update whenever launchd reported *any* pid for the label —
including the pre-update process the restart was supposed to replace.
f29ee96dd3 closed this for SIBLING labels only
(`_wait_for_launchd_service_pid(label, old_pid=old_pid, ...)`); the invoking
profile kept `wait_for_launchd_gateway_supervision` →
`_launchctl_label_supervising_process`, a predicate that is true for "launchctl
list exits 0 and some positive pid" and never compares against the pid seen
before the restart.

- `_launchctl_supervised_pid()` exposes the pid `_launchctl_label_supervising_process`
  already parsed; the boolean is now a thin wrapper over it.
- `wait_for_launchd_gateway_supervision(..., old_pid=...)` accepts a fresh pid
  only, mirroring the sibling loop's contract. `old_pid=None` keeps the old
  "any supervised pid" meaning for callers with no pre-restart observation.
- `_restart_launchd_gateway_after_update()` snapshots the pid before
  `launchd_restart()` and passes it in; the failure line now says launchd is
  not supervising a NEW process. The snapshot is verification-only, so the
  no-`launchctl list`-gating invariant of #74973 is untouched.

Also from round-2 review of this PR:
- the supervised-serve discharge test parametrized 5 supervisors, 3 of which
  the inventory writer can never put on a serve/dashboard row; cut to the two
  it can emit and documented the parity-only members.
- `_marker_only_restart_obsolete` now names who still owns a discharged
  launchd-supervised serve (update-time `report_unaccounted_runtimes`, exit 1).
2026-09-20 20:13:57 -07:00
teknium1
e9197afd6d fix(update): a supervised serve backend no longer pins the fleet-restart warning on forever
A `fleet_restart_pending` marker records the pre-update plan's runtimes as its inventory,
including `serve`/`dashboard` rows. `_marker_only_restart_obsolete` rejected every non-gateway
inventory row outright, so on any host that runs a dashboard or the Desktop `serve` backend the
marker could never discharge: `hermes version` kept printing "a previous `hermes update` ... did
not restart running gateways" after every gateway was already current on the pulled SHA.

Receipts already draw this boundary (`_receipt_owed_gateways`, #115090) and so does the restart
phase for the Desktop backend (#111494); the marker path was the one place left counting a
supervisor-owned serve row as evidence against the gateways it does not cover. A manual-serve row
still needs its durable handoff and an unclassified backend still fails closed.

Also corrects `_owed_stale_serve_rows`'s docstring, which justified the Desktop exclusion by the
"armed forever" consequence this change removes.
2026-09-20 20:13:57 -07:00
teknium1
a5d561b1ea fix: state the real failure mode of the model_config sanitiser
The docstring claimed every `json_extract` reader (`hermes sessions list`, the
dashboard chain CTE) raises on the recovered database. Both are guarded:
`_sql_json_extract` wraps each extract in `CASE WHEN json_valid`. The verified
symptom is the write path — `reopen_session` rewrites reset-child markers with
`json_set` and raises `OperationalError: malformed JSON` on the first resume.

The test drops its two mechanism assertions (`model_config == '{}'`,
`model_config_reset == 1`) and keeps `reopen_session('parent')`, the invariant that
is red on base; a different valid repair must not fail a working store.
2026-09-20 20:13:41 -07:00
teknium1
411a75a562 fix(gateway): auto-archive every served profile's store, not just the launch home
`hermes serve`'s opportunistic auto-archive now defers to `_check_gateway_running`
(the canonical per-profile predicate `_maybe_run_skill_maintenance` already uses)
instead of a hand-rolled `gateway.lock` probe. The lock is per PROCESS HOME, so a
multiplexed secondary's store still got a second writer from serve; the predicate's
multiplexer rung covers a profile that owns no gateway.pid of its own.

Deferring is only correct if the gateway actually sweeps that store, and it did not:
the "Auto-archive tick" chore resolves `acquire()`/`load_config()` through
`get_hermes_home()` and ran unscoped, so a served secondary was archived by nobody.
It now rides `profile_scoped_chore`, like the curator and sync ticks.

Invariants: the serve-side guard is proven against a real flock holder with a live
`gateway run` command line and a gateway.pid record (kernel state + process identity,
no patched predicate), and the tick is proven to sweep A's and B's state.db.
2026-09-20 20:13:41 -07:00
teknium1
a263df8dcd fix: backup import refuses to publish over a deleted-but-held state.db
`_import_db_member` guards the replace path with `_safe_restore_db`, but its
"target is missing" branch treated absence as proof of no holders. A gateway or
dashboard that had the database open when it was unlinked keeps writing the
deleted inode, so an atomic publish there re-creates exactly the #90950 split
brain the replace path exists to prevent.

Consult `_foreign_db_holder_pids` before the publish and raise an OSError
naming the PIDs, which the caller already reports as a skipped file.

Credit: @ngpestelos (#110179)
2026-09-20 20:13:41 -07:00
teknium1
7c2914becf fix: recovery no longer ships a store that raises on the first resume
`integrity_check` never inspects column contents, so a source whose
`sessions.model_config` JSON was truncated by the damage recovers "clean" and
the recovered store then raises `OperationalError: malformed JSON` the first
time a caller resumes a parent session — `reopen_session` rewrites reset-child
markers with `json_set`, which refuses unparseable input.

Reset unparseable blobs to '{}' in `_finalize_derived_metadata`, the one
boundary both the SQL-copy lane and the `.recover` lost_and_found lane pass
through; the count lands in the recovery report.

Credit: @efe-arv (#101679)
2026-09-20 20:13:41 -07:00
teknium1
b4f8d83e12 fix: hermes serve stops opening state.db writable under a live gateway
`_maybe_auto_archive_for_profile` ran 90s after bind (and on every dashboard
session-list request) and opened a WRITABLE SessionDB even when a gateway owned
the store. The gateway's own housekeeping already runs that sweep, so the serve
copy only added a second writer to a database another process is archiving.

Return early when the profile's gateway runtime lock is held.

Credit: @isair (#110405)
2026-09-20 20:13:41 -07:00
teknium1
f88ba2ea80 fix(console): accept the --force override the held-store refusal advertises
The Desktop/dashboard console printed `hermes sessions optimize --force` as the override, but
_sessions_optimize rejected every argument — with a gateway running the command could only
ever refuse. It now parses --force itself (and the hint names the console form).
2026-09-20 20:13:33 -07:00
teknium1
dfaf016dbc fix(sessions): resolve the held-store scan path from the store resolver, not the db object
The admission gate read `db.db_path`, which made the refusal depend on whatever object
`SessionDB` resolved to; the CLI tests substitute a lightweight double and CI went red with
AttributeError: 'FakeDB' object has no attribute 'db_path'. The path now comes from
`_default_db_path()` — the exact resolver `SessionDB()` itself uses two lines above — so the
scan targets the same file in production and stays reachable regardless of the db object.
2026-09-20 20:13:33 -07:00
teknium1
6ba45b0e06 fix(sessions): storage maintenance refuses while a writer holds state.db; human-first retired-WAL guard text + recovery guide
`hermes sessions optimize`, `optimize-storage` and `prune` now run the same fail-closed
holder scan doctor and repair use before rewriting the store. While a gateway, Desktop,
dashboard or cron process holds state.db (or a WAL sidecar) they print each holder as
`PID N (command)` with the stop remedy and exit 1; `--force` overrides with a warning,
`--dry-run` previews are never gated. The Desktop console's `sessions optimize` gets the
same refusal. Why: a user ran `optimize-storage` under a fleet of eight live gateways and
every agent answered every turn with the retired-WAL refusal until all writers were
stopped by hand (#110054, maintainer follow-up 09-20).

The DeletedWalGenerationError text is now two layers: a first sentence for the person
reading a chat bubble or banner (what happened, nothing is lost, quit every Hermes
process on the profile, `hermes doctor` names the holders, never `doctor --fix` or delete
files while they run, docs link), then the operator detail. The classifier fingerprint
"deleted state.db-wal or state.db-shm" is unchanged. The cause table
(`hermes_state_user_copy`, feeding the CLI banner, TUI/Desktop RPC error and the gateway
home-channel notice) and the chat explainer carry the same first steps; the gateway
notice no longer hardcodes `doctor --fix` + `gateway restart` for every non-corrupt cause,
which for a held retired generation is the second-writer trap.

New user-guide page `session-storage-recovery.md` (registered in sidebars, linked from the
guard text, the developer state-db-recovery page and the sessions guide): the three steps,
the do-nots, why maintenance refuses, and what the files beside state.db are
(retired-wal captures + manifest.json, pre-update-emergency backups, corrupt backups,
snapshots).
2026-09-20 20:13:33 -07:00
teknium1
12fb5eb476 fix(sessions): make set-journal-mode portable and fail closed where it cannot prove quiescence
Review follow-ups on the new `hermes sessions set-journal-mode` verb:

- The header probe used os.pread, which does not exist on Windows, while the subparser is
  registered unconditionally — the command died there with an uncaught AttributeError. It now
  reads the 20 header bytes through a plain binary open(), and the tests no longer skip on win32.
- foreign_state_db_holders() returns [] unconditionally on Windows (no scan), which made the
  admission gate vacuous: an operator got a silent all-clear and could flip the mode under a
  running gateway. Windows now refuses outright, naming the reason, overridable only by --force.
- Enabling WAL ignored the cross-VM filesystem refusal the runtime enforces
  (apply_wal_with_fallback). target=wal now refuses on virtiofs/9p, where WAL shared memory
  silently corrupts.
- A --db pointing at a garbage file surfaced a raw sqlite3.DatabaseError traceback even though the
  header probe had already read not-a-database, and a directory raised IsADirectoryError. Both now
  bail in the command's own error style; every open/read is guarded.

The admission checks that need no I/O live in a pure _refusal() that takes the platform as data,
so the Windows and cross-VM invariants are tested without faking sys.platform.
2026-09-20 20:13:25 -07:00
teknium1
96da5d97fc feat(sessions): hermes sessions set-journal-mode delete|wal converts an existing WAL store offline (#100896)
`database.journal_mode: delete` can never self-apply to a store that is already WAL:
apply_wal_with_fallback deliberately never live-downgrades (#68545 — other gateway/cron/worker
connections may hold uncheckpointed WAL commits), so operators applying the containment for the
multi-writer corruption class saw one ERROR per process forever and the only escape hatch was an
undocumented hand-run PRAGMA on the file.

The new pre-DB `sessions set-journal-mode` verb is the sanctioned offline path: it refuses while ANY
foreign process holds the file or a sidecar (the same foreign_state_db_holders scan doctor/repair
admission uses, naming each PID), flips through _set_journal_mode_no_wait (busy_timeout=0, so an
opener appearing mid-way makes SQLite refuse instead of racing it), verifies header bytes 18/19,
and reminds the operator when config.yaml disagrees. `--db PATH` covers kanban.db / cron stores
that log the same ERROR. The never-live-downgrade invariant is untouched; the ERROR, doctor hints
and docs now name the command instead of the raw PRAGMA.
2026-09-20 20:13:25 -07:00
Austin Pickett
1a1f4a59e2 fix(auth): explicit-provider gate uses the credential resolver's reader
get_env_value stops at the first environ hit, so a shell that exports
DEEPSEEK_API_KEY= (empty) hides a real key in .env from the gate while
resolve_api_key_provider_credentials() finds it: the picker omits a provider
the chat path would authenticate with. Read through
get_env_value_prefer_dotenv, the same chain auth.py already uses to resolve
the key, so the two can never disagree (#77007).

Co-authored-by: webtecnica <webtecnica@gmail.com>
2026-09-20 22:46:49 -04:00
xxxigm
4ea57fa61b fix(auth): count profile .env keys in the explicit provider gate
os.getenv saw only the launch profile, so a DeepSeek key pasted in
another profile never appeared in Settings → Model until Refresh
models ran against that Bot's own backend.
2026-09-20 22:46:49 -04:00
teknium1
c746249861 fix(kanban): CLI and kanban_complete tool report an empty-completion refusal
EmptyCompletionError is raised by complete_task; without a handler the
CLI and the tool would surface a traceback instead of the actionable
refusal (add --result/--summary). Same shape as the HallucinatedCards gate.

Part of #117483
2026-09-20 19:03:47 -07:00
Bartok9
6504b665ba fix(kanban): refuse empty complete_task evidence (#117483)
Completing a card with no result/summary (or whitespace-only) left
done rows with no handover. Gate before the write txn, audit
completion_blocked_empty_result, raise EmptyCompletionError.
Review approvals stay exempt.
2026-09-20 19:03:47 -07:00
teknium1
c545272568 fix(lsp): one stalled request no longer silences a workspace for good — retry window, cold-root warm-up budget, per-root exclusion
A language server that missed its budget once marked its (server, root) pair broken for the
process lifetime, the same 5 s steady-state budget was applied to a cold server that also had
to spawn, initialize and build its program, and the only escape (servers.<id>.disabled) switched
the server off for every workspace.  Three new keys under the existing `lsp` block, all defaulting
to today's behaviour:

- lsp.broken_retry_seconds (0 = lifetime): the broken set stores a monotonic retry deadline per
  pair; an expired pair gets one more try, and the INFO skip line names the retry time.
- lsp.warmup_timeout (0 = wait_timeout): the first request against a root with no running client
  waits up to this budget (outer join budget follows); warm requests keep wait_timeout.
- lsp.exclude_roots ([]): glob patterns matched against the resolved project root (a bare path
  also covers everything beneath it); a matching root never spawns, logged once at INFO.  A
  non-list value fails closed — WARNING naming the expected shape, every root skipped — because
  silently excluding nothing would re-pay the stall the key was meant to avoid.

Part of #116446 (the diagnosability slice landed in #116839, salvage of #116459 by @kokhlo).
2026-09-20 18:54:24 -07:00
teknium1
442636bf44 fix(update): settle latest.json only when the live fleet discharges the pending restart
The catch-up rewrote the receipt's fleet matrix before checking whether that
actually cleared the debt, so an exit-1 run (marker still owes a gateway,
blocked serve_restart_pending storage) left latest.json mutated —
tests/hermes_cli/test_update_scoped_reconciliation.py pins the receipt as
byte-identical on the failure path. Decide on the in-memory settled copy and
write only when the pending restart is discharged (#117051).
2026-09-20 18:52:45 -07:00
teknium1
29c4ec8fa7 fix(update): settle a stuck failed receipt once the live fleet serves the checkout code
A failed update receipt whose plan rows cannot be matched to a live gateway
(unknown profile identity, pre-pull SHAs, empty fleet matrix) could never be
discharged: every up-to-date `hermes update` ran the pending restart, printed
"Pending fleet restart completed." followed by "Fleet restart incomplete",
exited 1 and wrote another failed receipt — and every CLI start kept warning
about mixed sys.modules, even after a manual `hermes gateway restart` had put
the fleet on the checkout code (#117051, atoms 3/4).

In _apply_pending_fleet_restart_catchup, when the restart succeeded but the
receipt still owes gateways, probe the live fleet; if every row is current at
the checkout SHA, persist that matrix as latest.json's post-restart `fleet`
(update_receipt.settle_latest_receipt_fleet) so _receipt_reports_stale_runtime
and the startup warning see the recovery. The "incomplete" line now only
prints when the restart itself failed; the residual case names what is still
off the checkout code instead of contradicting the completion line.

Fixes #117051
2026-09-20 18:52:45 -07:00
teknium1
d6b0d37ece fix(update): pending fleet restart leaves gateways already on the checkout code alone
The catch-up restart stopped every gateway before re-checking whether any was actually on
pre-update code, so the gateway the same `hermes update` had just cold-started was killed
and (on Windows) the stop/start pair printed "No gateway was running" followed by a second
spawn. _run_pending_fleet_restart now skips when the fleet probe shows every live gateway
current on the checkout SHA (identity known; any stale/down/unknown row still restarts).
The Windows direct-spawn report no longer prints the PID twice ("(PID n) (PID: n)").

Part of #117051
2026-09-20 18:52:45 -07:00
teknium1
eaef5ec717 feat(update): hermes update --list-venv-holders prints the venv guard's holders as JSON, exit 3
Scheduled `hermes update --yes` runs on Windows loop against the venv-holder guard when the
Desktop app relaunches its backend, and the refusal text is the only clue. The new read-only
flag runs the same scan (_detect_venv_python_processes, late-bound through hermes_cli.main)
and the same classifiers (pausable-gateway matcher, _hermes_holder_subcommand) and prints
[{pid, exe, argv, kind}], exiting 0 when the venv is free and 3 when holders remain, so
automation can stop exactly those PIDs and retry. Nothing is terminated; the flag is
handled in the update preflight before the lock, backup, or any mutation. Off Windows the
guard never fires and the list is [].

Fixes #117246
2026-09-20 18:51:36 -07:00
John Paul Soliva
55a8f42329 perf(profiles): the listing re-reads a profile's YAML only when that file has changed
`list_profiles()` parsed three YAML files per profile on every call — config.yaml through
`read_user_config_raw` ("no cache", by contract) plus profile.yaml and distribution.yaml through
`_load_yaml_dict`. It is the shared body of `GET /api/profiles` and `profiles.list`, which the Bots
roster polls every 5s per connection, so an idle machine re-parsed every profile's YAML twelve
times a minute to produce the same handful of strings.

config.yaml is the expensive one: the installer seeds it by copying the annotated template verbatim
(`cp cli-config.yaml.example config.yaml`), 119,268 bytes / 2,267 lines of mostly comments, for
`model.default` and `model.provider`.

Measured, 9 profiles, no gateway locks so the liveness path stays out of it: 16.1ms -> 1.6ms with
the installer template, 6.4ms -> 1.6ms hand-trimmed; 27 YAML parses per pass -> 2, and those two
are the default profile's ABSENT files, correctly not cached. The pass is now independent of config
size.

The memo keys on each file's (mtime_ns, size, inode) — the atomic writers rename a temp file into
place, so a rewrite always lands a new inode even inside one mtime tick — and caches only the small
DERIVED values. `read_user_config_raw` and `_load_yaml_dict` keep their uncached contract: callers
write those documents back, and a stale read there could overwrite a newer file. `read_profile_meta`
hands out a copy so a future mutating caller cannot poison the next reader.

Regressions drive the real readers: an edited config/profile/distribution file is seen on the very
next read, `write_profile_meta` round-trips, a missing file is not cached and is picked up when
created, mutating a returned meta does not poison the next reader, an unchanged file is parsed once
across three reads, and `read_user_config_raw` still re-reads. Ignoring the signature fails four.

Fixes #117378
2026-09-20 18:45:07 -07:00
fangliquanflq
1a4c68ce97 fix(kanban): expose task runtime limit in JSON 2026-09-20 18:43:59 -07:00
chadhouser
79c4c363c6 fix(kanban): derive review implementer from the run, not the card's assignee
request_review recorded trow["assignee"] as the implementer on the
review_requested event and on the synthesized run. That is correct while a
worker holds the card, but wrong when the card was created already assigned to
its reviewer -- `kanban create --assignee <reviewer>` followed by
`request-review`. Both provenance records then name the reviewer, and
request_changes routes a rejection back to the profile that wrote the findings.

Derive the implementer after the reviewer is resolved, from the active run's
profile, falling back to the assignee only when it is not the incoming
reviewer. When no actor can be established the field is left unset, which
request_changes already refuses on rather than misrouting.

Refs #117229. Follow-up to #111064 / #111459, which fixed the run attribution
for the distinct-reviewer case and left the derivation itself unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:43:35 -07:00
teknium1
aaafa80577 refactor(cli): move HermesCLI init phases and TUI run-loop phases into mixin siblings (#116911)
Mechanical, behaviour-neutral extraction along the existing cli_*_mixin.py
pattern: the ten _init_* constructor phases become CLIInitMixin
(hermes_cli/cli_init_mixin.py) and the fourteen _tui_* run-loop phases
(input dispatch, after-turn, startup banner/prewarm/maintenance, application
build, signal handlers, shutdown) become CLITuiRuntimeMixin
(hermes_cli/cli_tui_runtime_mixin.py). __init__ and run() stay in cli.py as
the orchestrators. Every moved body is AST-identical to the base copy; cli.py
module names are resolved lazily through cli so monkeypatch seams survive.
cli.py 4835 -> 3960 lines.
2026-09-20 18:43:11 -07:00
teknium1
70fceb80ab refactor(gateway): move the systemd service-unit cluster out of hermes_cli/gateway.py
Mechanical extraction of the service-definition cluster (generate_systemd_unit,
systemd_unit_is_current, refresh_systemd_unit_if_needed and their
_systemd_*/_service_*/_ld_library_path/_temp_home helpers) into
hermes_cli/gateway_service_unit.py. Behaviour-neutral: moved bodies read facade
helpers (get_python_path, _profile_arg, _run_systemctl, load_gateway_config, ...)
and each other through _gw() — a late import of hermes_cli.gateway — so every
seam tests and callers patch on the facade keeps intercepting the moved code;
the facade keeps one import block re-binding the names.

hermes_cli/gateway.py: 6808 -> 6496 lines.

Part of #116913
2026-09-20 18:33:07 -07:00
teknium1
5aeddb6c85 fix(auth): user provider plugin owns its aliases and display name in the auth registry
register_plugin_provider setdefault-ed aliases regardless of provenance, so a
$HERMES_HOME plugin alias colliding with an existing row kept resolving to the
old row while providers.get_provider_profile already followed the user profile;
override_registry_row left the display name untouched on a same-name
replacement. mirror_aliases applies the provider_source ownership rule that
e5e7fbcd27 introduced for endpoints.

Fixes #116668
2026-09-20 18:29:22 -07:00
rodricksz4h5
f50c6f43a2 fix(cli): let the early interface probe follow the profile's home
`_suppress_mouse_residue_early()` runs at import time, before
`_apply_profile_override()` sets HERMES_HOME, so the first read of
`display.interface` always saw the default home. The result was memoised
in a cache keyed on nothing, so every later caller — the Termux fast
paths and the TUI launch decision, which all run after the profile is
applied — was pinned to the default home's interface for the whole run.
`hermes -p coder` with `display.interface: tui` in that profile booted
the classic REPL, and the mirror case booted the TUI for a profile that
asked for cli.

Key the cache on the config path it read. The hot path still parses the
YAML once per home, and the first call after the process is re-homed
re-reads the file it should have read.

Fixes #116902
2026-09-20 18:26:14 -07:00
rodricksz4h5
b3ae818261 fix(cli): attribute a scrubbed-env dashboard to its owner's home
`_hermes_home_for_pid` fell back to the INSPECTING process's `Path.home()`
when the target's environment carried neither HERMES_HOME nor HOME, which
is the normal shape for a systemd/launchd unit with a scrubbed
environment. The dashboard was then attributed to whichever user ran the
command, so `hermes update` and `--stop` read the wrong profile root and
could act on the wrong backend.

A process without HOME resolves its own default home from the password
database, so resolve the target's owner the same way (psutil, then the
/proc owner) before falling back. An unreadable owner or entry keeps the
previous behaviour rather than resolving to nothing.

Fixes #116906
2026-09-20 18:25:48 -07:00
teknium1
fafa2e215c refactor(cli): route /browser subcommands through a dispatch table (#116914)
The 5-branch if/elif ladder in CLICommandsMixin._handle_browser_command
becomes a _BROWSER_SUBCOMMANDS word -> handler table; adding a subcommand
is one row plus a usage line. Subcommand word is case-insensitive, the
argument keeps its case (CDP URL paths are case-sensitive).
2026-09-20 18:23:02 -07:00
teknium1
f76bdb6e8f fix(auth): PKCE token errors reuse the canonical grant-dead code set
Drop the duplicated _GRANT_DEAD_CODES frozenset and the relogin_required
plumbing: the pool's plugin recovery already treats exc.code in
hermes_cli.auth._OAUTH_GRANT_DEAD_CODES as terminal, so _token_http_error
imports that set late and just sets code=<json error>.
2026-09-20 18:21:34 -07:00
Adolanium
5eb0659177 fix(auth): mark PKCE invalid_grant dead and store alias logins canonically
A spent refresh token returned HTTP 400 invalid_grant, and the helper mapped that to oauth_refresh_failed so the pool EXHAUSTED the row. Parse the JSON error and DEAD it. hermes auth add with an alias wrote the pool under the typed name, so status against the profile name looked logged out.
2026-09-20 18:21:34 -07:00
fangliquan
7df20a670d fix(cli): fail closed without bang shell sanitizer 2026-09-20 18:20:44 -07:00
teknium1
dcae0d81d0 fix(dashboard): fold the new fallback config section into the agent tab
fallback.min_switch_reset_seconds is the only schema-surfaced fallback field; without the merge the dashboard grew a one-field orphan tab (test_no_single_field_categories red on CI).
2026-09-20 17:00:43 -07:00
teknium1
f568b860d7 fix(agent): pin the reset_at reach through try_activate_fallback and add opt-in fallback.min_switch_reset_seconds
- The direct _arm_rate_limit_cooldown test now drives agent._try_activate_fallback
  (production entry) on a real AIAgent with a one-entry chain, so dropping the
  reset_at forwarding goes red (3 failures before, 8 green after).
- #117484 knob: fallback.min_switch_reset_seconds (DEFAULT_CONFIG 0 = off). When the
  rate-limited primary's declared reset is sooner than N seconds,
  try_activate_fallback returns False and no cooldown is armed; docs row added.
2026-09-20 17:00:43 -07:00
teknium1
348568ddaf fix(profiles): hoist the profile-id grammar into hermes_constants.PROFILE_ID_RE
Review follow-up for #116905: bot_mode_probe was the third private copy of
the regex (profiles, update_restart_recovery) with a sync test policing the
drift the copy created. One canonical object next to named_profile_is_live;
the three modules import it and the sync test is gone.
2026-09-20 17:00:11 -07:00
teknium1
d325e53116 fix(kanban): drop the dead edit_completed_task_result shim and route dashboard priority edits through edit_task
Review follow-up for #117434: edit_completed_task_result had no callers
after edit_task absorbed it; the dashboard's _set_priority kept its own raw
UPDATE + reprioritized INSERT, so edit_task gains a board= passthrough for
the post-commit observer and becomes the single reprioritize primitive.
2026-09-20 16:33:49 -07:00
fangliquanflq
3f399c0bd4 fix(cli): align kanban edit and paused cron listing 2026-09-20 16:33:49 -07:00
teknium1
891c1d79c6 fix(constants): one home for managed, container and HERMES_UID policy; drop the config twins
Review follow-up: get_managed_system, the container/chmod-skip check and
the HERMES_UID/GID chown (_resolve_hermes_uid_gid/_chown_to_hermes_uid) now
live only in hermes_constants — the import-safe module apply_secure_dir_policy
already needed them in — and hermes_cli.config re-exports them, so there are
no 'keep in sync' copies and _secure_file skips on the same canonical
_detect_container signal as _secure_dir/get_scratch_dir. The dead config
copies are deleted and test_ensure_hermes_home_uid.py drives the new
symbols; one invariant test pins the single implementation.
2026-09-20 16:32:50 -07:00
liuhao1024
a5605724c2 fix(constants): honor managed/shared-home permission policy in get_scratch_dir
get_scratch_dir unconditionally chmod'ed <home>/cache/scratch to 0700,
bypassing the policy _secure_dir() applies everywhere else: managed
installs are left alone, containers only apply an explicit
HERMES_HOME_MODE, and HERMES_UID/HERMES_GID ownership is honored. On a
group-shared home (setgid 2770) that stripped the setgid bit and broke
the shared-group inheritance the operator configured (#77579, #117347).

Move the policy primitive to hermes_constants.apply_secure_dir_policy()
(import-safe, stdlib-only) and route both get_scratch_dir() and
hermes_cli.config._secure_dir() through it, so one implementation
serves all callers including the cron.jobs delegation chain.

Fixes #117347
2026-09-20 16:32:50 -07:00
teknium1
265e68d784 fix(auth): one primary_failure_wording() helper labels quota vs auth failure at all three fallback surfaces
- hermes_cli.auth.primary_failure_wording(exc) -> (log, user) phrase; reused by
  cli_agent_setup_mixin._resolve_fallback_runtime, runtime_provider's fallback
  logger and the TUI gateway/Desktop _resolve_runtime_with_fallback (#117482
  sibling: 'Primary auth failed' for a 429 on the gateway surface).
- Drop the dead credentials_rate_limited kwarg at the post-turn exit site (the
  flag is only True when _ensure_runtime_credentials returned False).
- Fold the new tests: 2 parametrized production-path tests + 1 gateway test.
2026-09-20 16:23:22 -07:00
teknium1
75430ff290 refactor: one fallback notice call, label chosen up front
Fold the duplicated render_notification branches into a single call and re-point the
source-slicing test marker at the flattened block.
2026-09-20 16:23:22 -07:00
686f6c61
89096ba56c fix(cli): treat credential-resolution 429 as quota, not auth
A Codex quota wall at startup was labeled "Primary auth failed" and
exited 1, so Kanban counted a failure and operators went looking for
a bad token. Use is_rate_limited_auth_error() for the fallback notice
and EX_TEMPFAIL (75) when HERMES_KANBAN_TASK is set.
2026-09-20 16:23:22 -07:00
teknium1
214623389a fix(models_validate): one profile-catalog helper decides accept/reject before the generic listing
Fold _profile_owns_catalog and _profile_owned_catalog into _profile_catalog ->
(catalog, authoritative) so _validate_live_listing has a single owned-catalog
branch; drop the redundant catalog-hit test (covered by the owns-catalog accept
tests in test_setup_provider_catalog.py).
2026-09-20 16:22:25 -07:00