Commit Graph

20877 Commits

Author SHA1 Message Date
ethernet 7112fbcbcf fix(nix): update electron sha 2026-08-05 17:50:18 -04:00
kshitij 145e777314 test(cron): close a blind spot in the kanban env drift guard
The AST invariant only matched `env["HERMES_KANBAN_X"] = ...` subscript
assignments, so a future dispatcher var added via `env.update({...})`,
`env.setdefault(...)`, or an annotated subscript would have slipped past
the guard and leaked into cron sessions unnoticed.

None of those shapes exist in _default_spawn today; this is about the
guard staying trustworthy as that function evolves.

Verified by injecting an unregistered var into _default_spawn one shape at
a time and requiring the guard to fail: subscript assign, annotated
assign, update(dict literal), setdefault(literal), and update(kwarg) are
all detected. Source restored byte-identical after probing.
tests/cron/ 410 passed; ruff clean.
2026-08-06 03:16:26 +05:30
kshitij 80f37e36ed fix(cron): don't let a cron job inherit a kanban worker's dispatcher identity
A kanban worker that fires a cron job in-process no longer leaks its task
identity into the cron agent.

The worker is a normal `hermes chat -q` CLI agent whose default toolset
includes `cronjob`, running with HERMES_KANBAN_TASK legitimately set in its
own environment. `cronjob(action="run")` calls run_one_job() -> run_job()
in that same process, so the cron AIAgent was misidentified as that worker:
kanban toolset force-added, kanban-worker protocol injected into its system
prompt, and kanban_complete defaulting task_id to $HERMES_KANBAN_TASK --
letting an unrelated cron job close the worker's task and overwrite real
results.

Fixed with a ContextVar (`non_dispatcher_owned_context`), not by clearing
os.environ. The env is process-global and shared with three concurrent
readers that all need the real values:

  * the worker's own claim heartbeat -- run_agent._touch_activity ->
    heartbeat_current_worker_from_env reads TASK/CLAIM_LOCK/RUN_ID, and the
    cron-run heartbeat thread drives it every 10s. Clearing them silently
    no-ops the heartbeat, so after DEFAULT_CLAIM_TTL_SECONDS (15 min) the
    dispatcher reclaims a task whose worker is still alive and re-dispatches
    it -- the same duplicate-work failure from the other direction.
  * the gateway's kanban watchers, which do their own HERMES_KANBAN_BOARD
    save/restore around a slow decompose_task() LLM call.
  * concurrent cron jobs, which take a *shared* read lock
    (_terminal_cwd_lock.acquire_read) and so interleave: job A clears, job B
    snapshots empty, A restores, B clears and its restore no-ops -- the
    worker's identity is destroyed permanently.

`is_dispatcher_owned_worker_context()` is now the single predicate every
HERMES_KANBAN_* identity gate consults before trusting those vars. It also
closes a pre-existing gap in agent/skill_utils.py, which read the vars
without consulting the delegate_task ContextVar at all; the `kanban` verdict
additionally bypasses _ENV_DETECT_CACHE, since a context-dependent answer
must not be memoized process-wide.

HERMES_KANBAN_BOARD/DB/WORKSPACES_ROOT are left untouched, so the #20074
board pin and the dispatcher's path overrides keep working.

Tests: 18 new, including thread-isolation, concurrent-cron-jobs, and an AST
invariant over _default_spawn that fails if the dispatcher gains a var that
is neither identity-gated nor explicitly classified behaviour-only. All six
mutations are caught, including one that reintroduces the os.environ clear.
tests/cron/ + kanban suites 440 passed; model_tools/skill_utils/boards 63
passed; ruff clean.

Reported and diagnosed by Geoff Friesen (#78961), who identified the symptom
and the exact gating mechanism.

Co-authored-by: Geoff Friesen <gfriesen1@users.noreply.github.com>
2026-08-06 03:16:26 +05:30
kshitij 68ebb198c7 fix(gateway): don't claim deleted head chunks as delivered in the empty-fallback recovery
Follow-up to #79669. That PR routed the three fallback recorder sites through
_record_turn_final_payload so a split turn would record the unsplit ledger
instead of a tail-only payload. For two of them that is right. For
_send_empty_fallback_final it is wrong, and it reintroduces the #78541 swallow
at the one site that was supposed to be fixed.

_send_empty_fallback_final is a *replacement* recovery: it sends the completed
text as a fresh message and deletes every tracked segment preview -- which on an
overflow split includes the sealed head chunks. After it runs, the only thing
on screen is the message it just sent. Recording the ledger there claims
delivery for text the same function just removed, so delivered_final_matches()
returns True, the gateway suppresses its own send, and the user is left with a
fraction of the answer.

Observed with a probe driving the real run() loop (543-char reply, 475-char head
sealed then deleted, 67-char tail committed):

  before this fix        recorded=543  matches=True   -> suppressed, 67/543 on screen
  after  this fix        recorded=67   matches=False  -> gateway sends the full answer

Record final_text verbatim here instead. The sibling site in
_send_fallback_final keeps the recorder: its delete is gated on
`continuation == final_text` and targets only the single active partial, never
the sealed heads, so the ledger correctly describes what survives.

The distinction is whether a recovery ADDS to what is on screen or REPLACES it.
Additive paths may record the ledger; replacing paths must record only what they
leave behind. _try_fresh_final is the same shape and #79669 handled it by
refusing the route on split turns.

Test drives the real seal-then-delete sequence and asserts the mismatch, so the
gateway is required to re-send. Mutation-checked: restoring the recorder call
turns it red.
2026-08-06 03:13:32 +05:30
kshitij 392e3a8c53 fix(gateway): finish the split-delivery bug class so the fix cannot duplicate or still swallow
The salvaged fix changed only the gateway's verdict: a payload-less
multi-message split stopped inheriting legacy trust. But six code paths set
_turn_split_delivery, and only one of them was taught to record a payload, so
the remaining five swapped the swallow for the opposite defect.

Fix the producers instead of only distrusting them at the boundary:

- _send_or_edit failed-final-edit branch: record the visible payload on split
  turns too. It deliberately skipped recording, which now reads as a mismatch
  and re-sends an answer already on screen -- reintroducing the duplicate
  #45517 fixed (#36965 / #25349).
- _send_fallback_final (x2) and _send_empty_fallback_final: route through
  _record_turn_final_payload instead of assigning _delivered_final_text
  directly. On a split turn their final_text is only the trailing chunk, so a
  fully delivered heads+tail reply recorded a tail-only payload and was
  re-sent in full.
- _try_fresh_final: refuse the fresh-final route once a head chunk is sealed.
  It replaces every tracked preview with one message, which only holds the
  whole answer on a single-message turn. After a split it deleted the sealed
  heads while sending just the tail, so the complete reply was still lost --
  on Telegram, the default finalize route and the shape #78541 reports.
- Set _turn_split_delivery at seal time rather than after the tail send, so
  the tail's own finalize sees the split state. The sibling overflow path
  already did this; the divergence is what let fresh-final delete the heads.
- run.py stale-finalize reconciliation: skip the in-place edit on a split
  delivery. message_id is only the LAST chunk there, so editing it with the
  complete response repeated every sealed head's text inside the tail
  message. Fall through to the normal final send.

Also drop a dead `or "".join(chunks)` fallback (all growth funnels through
_append_accumulated, so the ledger is never empty at that call site, and joined
chunks carry injected fence markers that could never match final_response), and
document that _record_turn_final_payload intentionally ignores its argument on
split turns.

Tests: four end-to-end cases driving the real overflow-split loop instead of
hand-setting private flags -- complete split still suppresses (no duplicate),
split missing a tail does not suppress, fresh-final keeps sealed heads, and a
flood-controlled final edit after a split stays suppressed. Each was
mutation-checked: reverting any individual fix turns its test red.

The pre-existing gateway-boundary test asserted the recovery *route* (the
reconcile edit) rather than the guarantee. Relaxed to the real contract: either
_run_agent puts the complete text on the wire, or it declines to claim delivery
so the caller's normal final send does.

Co-authored-by: HexLab98 <liruixinch@outlook.com>
2026-08-06 02:54:32 +05:30
HexLab98 a2ca5c2a78 test(gateway): cover payload-less split-delivery final-send swallow
Add unit and GatewayRunner boundary coverage for the #78541 shape where
final_content_delivered is set via split delivery with no recorded
payload.
2026-08-06 02:54:32 +05:30
HexLab98 c46027b049 fix(gateway): stop payload-less split delivery from swallowing finals
Record an unsplit stream ledger for multi-message deliveries and refuse
legacy trust when split delivery left no payload, so Telegram group
sessions no longer suppress a complete reply after an early/partial
finalize (#78541).
2026-08-06 02:54:32 +05:30
Teknium d1f9e77755 chore: map justin@actual.computer to somewheresy for #26491 salvage 2026-08-05 14:08:32 -07:00
Teknium 5aa798fecc feat(skills): actual-setup optional skill + provider docs
- optional-skills/devops/actual-setup: field-tested setup skill contributed
  by shl0ms, updated for the first-class 'actual' provider (the original
  targeted a custom-provider config that now collides with the built-in name)
- docs: providers.md section + tables, environment-variables.md, quickstart.md
- tests/skills: frontmatter + first-class-provider conformance checks
2026-08-05 14:08:32 -07:00
Teknium e79f16cab6 feat(providers): env-var metadata, config-driven local no-auth, reasoning-effort clamp for Actual
- config_defaults: ACTUAL_API_KEY / ACTUAL_BASE_URL entries (setup wizard + hermes tools)
- codex transport: clamp xhigh->high, ultra->max for provider=actual (SGLang/vLLM
  backends reject the wider values with a wrapped HTTP 400)
- chat_completion_helpers: thread provider into Responses build_kwargs
- tests: transport clamp + config-driven local no-auth regression
2026-08-05 14:08:32 -07:00
Teknium b6d55a790e fix: adapt Actual provider salvage to current main
- fetch_models(): accept base_url kwarg (interface grew on main since May)
- runtime_provider: config-driven loopback base_url now reaches the local
  no-auth placeholder before the usable-secret gate (added on main in the
  interim, would otherwise AuthError on keyless local setups)
- test: fetch is now called with base_url by the generic live-fetch path
2026-08-05 14:08:32 -07:00
Justin Bennington a9acb400ba feat(providers): add Actual Computer inference provider 2026-08-05 14:08:32 -07:00
kshitij 241605d1ea fix(compression): durable-sync the prune runway on model switch + fast no-op for incapable stores
Three review follow-ups on the salvaged #79286 commit:

- update_model() zeroed the in-memory prune runway but left the durable
  model_config copy stale, breaking the method's own durable-sync
  discipline (the strike reset three lines above keeps its durable copy
  in sync). A restart after a model switch resurrected a runway
  computed under the old model's trigger sizes. New
  _clear_durable_proactive_prune_rearm() removes the persisted key via
  patch_session_model_config() without touching the transcript.

- The archive_and_compact capability check ran AFTER the expensive
  3-pass prune scan, so a duck-typed session store lacking the method
  paid the full scan on every eligible iteration forever with pruning
  permanently no-opping. Hoist it above the scan (all in-tree stores
  pass a real SessionDB; this only affects third-party stores).

- _load_proactive_prune_rearm_tokens now uses the shared
  get_session_model_config_value() accessor instead of inlining a 5th
  copy of the model_config JSON parse, matching its sibling loaders'
  typed-accessor pattern.

Also documents why the rotation-publish-failure branch restores only
the runway field rather than the full attempt snapshot.

Tests: model-switch durable clear, patch_session_model_config
merge/delete/no-op, and a guard proving incapable stores skip the scan.
2026-08-06 02:22:08 +05:30
kshitij 565b2c42eb refactor(state): extract shared model_config merge helper
archive_and_compact's new model_config_patch block was the third
near-identical SELECT -> tolerant-parse -> merge -> UPDATE copy in
hermes_state.py (update_session_runtime_lock and set_session_yolo carry
the other two). Extract _merge_model_config_json(conn, sid, patch,
on_missing=...) and route all three through it, preserving each
caller's missing-row policy (flag setters skip, archive raises).

Also adds the two small accessors the compressor needs:
- patch_session_model_config(): standalone atomic merge for callers
  that must update model_config without rewriting the transcript
- get_session_model_config_value(): tolerant single-key read

Follow-up to the salvaged #79286 commit, per the repo's
extend-don't-duplicate rule.
2026-08-06 02:22:08 +05:30
Ryder Freeman bf6a210ab9 fix(cache): make proactive pruning durable and cache-aware 2026-08-06 02:22:08 +05:30
Teknium ced8e30217 feat(scripts): reproducible core-toolset A/B eval harness (toolperf_abeval)
Ships the hard A/B evaluation used for the August 2026 core-toolset
performance batch (#77056) as a reusable harness: 9 error-inducing trap
tasks derived from measured production waste classes, two-arm
PYTHONPATH-only comparison, ATOF-trace-based scoring, resume-safe
batteries.

Hardened from the original one-off: paths de-hardcoded (ABEVAL_ROOT /
ABEVAL_HOME), encoding= on all file IO, startup crashes retry on resume
instead of polluting cells, post-hoc grading fix for err_inline_script
baked in. Live-smoked end to end (baseline arm, qwen3-coder-30b,
err_multi_dir: exit 0, correct on-disk verification, resume record
written).
2026-08-05 13:43:30 -07:00
Austin Pickett fb402106f8
fix(dashboard): auto-reconnect the events WebSocket with backoff (supersedes #47876, #47921, #24315) (#79524)
* fix(dashboard): add events-feed reconnect policy helpers

Extract the reconnect arithmetic and close-code classification for the
ChatSidebar /api/events socket into a pure module so both can be tested
without a fake WebSocket or a mounted component.

Two decisions live here rather than inline in the effect:

- `shouldRetryEventsClose` — 1000 (normal) and 4401/4403 (auth) are
  terminal; everything else, including 1005/1006 from a killed gateway
  or a dropped network, is retryable.
- `isEventsFeedMessage` — the sidebar's banner is shared with
  `info.credential_warning` and the JSON-RPC sidecar, so a reconnect may
  only clear a message the events feed wrote itself.

Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>

* fix(dashboard): auto-reconnect the events WebSocket with backoff

The chat sidebar's /api/events subscriber surfaced a static "disconnected"
banner on a transient drop and never retried, so a gateway restart or a
network blip left the feed dead until the user reloaded the page. The feed
drives the live chat title (session.info) and dashboard.new_session_requested,
both of which silently stopped working.

Reconnect with exponential backoff (1s → 2s → 4s → … → 30s cap, 15 attempts
then a terminal banner). Specifically:

- One scheduling path. `close` always follows `error` for a failed socket,
  so scheduling from both — as the superseded PRs did — queues two timers
  and leaks the one that is no longer tracked for cleanup. `scheduleReconnect`
  returns early when a retry is already pending.
- The auth ticket is re-minted per attempt via `buildWsUrl`; tickets are
  single-use with a short TTL, so replaying the first URL would 4401 on the
  second attempt.
- A superseded socket's late close cannot schedule a retry on top of its
  replacement (`isCurrent` generation check).
- A successful open resets the backoff and clears only the events feed's own
  banner, leaving a credential warning or sidecar error visible.
- The pending timer is cleared on unmount, not merely neutered by the
  `unmounting` flag.

Also retires two strings the tools box left behind when it was removed in
47fccc073 (#51737): the banner no longer promises "tool calls may not
appear" and the button reads "reconnect events feed".

Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>

* test(dashboard): cover the events-feed reconnect bug class

Fake-timer coverage for the behaviors the superseded PRs changed without
tests. Each case was mutation-checked — reverting the corresponding guard
in ChatSidebar.tsx makes exactly that test fail:

- transient close reconnects, and backoff grows 1s → 2s → 4s
- error + close on one socket schedules ONE retry, not two
- a successful open resets the backoff to 1s
- 4401/4403 and a normal 1000 close never retry
- the attempt cap stops the loop instead of retrying forever
- reconnect clears the feed's own banner but not a credential warning
- unmount clears the pending timer (asserted via `vi.getTimerCount()`,
  since the `unmounting` flag alone hides a leaked timer)

Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>

* fix(dashboard): stop the events feed overwriting a foreign banner

Review catch: `clearEventsBanner` guarded the shared banner but `surface`
did not, so the guard was only half applied. A sidecar error or
`credential_warning` already on screen when the feed dropped was replaced
by "events feed disconnected" — and lost for good, since `error` is that
message's only home and the sidecar does not re-emit.

`surface` now writes only over an empty banner or one of the feed's own
messages. Declining to write does not affect the retry itself; the
reconnect still runs on schedule, it just stays silent while a more
important message holds the banner.

Both directions are covered: a foreign banner survives a drop, and the
reconnect still fires while suppressed.

---------

Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>
2026-08-05 14:27:04 -06:00
Jeffrey Quesnelle 6564f319a6
Merge pull request #69416 from afourniernv/feat/hermes-relay-install-activation-metrics
feat(observability): add Relay active install metrics
2026-08-05 14:09:25 -04:00
Jeffrey Quesnelle edf0a7e14b
Merge pull request #68978 from afourniernv/feat/hermes-relay-client-dimensions
feat(observability): add Relay client resource metrics
2026-08-05 14:02:28 -04:00
Jeffrey Quesnelle 0531aad55d
Merge pull request #68883 from afourniernv/feat/hermes-relay-skill-metrics
feat(observability): aggregate bounded skill metrics
2026-08-05 13:20:57 -04:00
hermes-seaeye[bot] 25c7827ec9
fmt(js): `npm run fix` on merge (#79521)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 16:51:56 +00:00
brooklyn! 69bedb7bed
fix(desktop): stop reporting false failure on successful backend updates (#79513)
* feat(update): emit an action-scoped terminal receipt from hermes update

The dashboard now mints an action_id per backend update, hands it to the
spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight
update action instead of spawning a duplicate. The updater prints a
bounded `=== hermes-update completed <id> ===` receipt on every success
path — normal, zip, dependency-repair, and the no-op "Already up to
date!" path that previously ended with no terminal marker at all
(#58764) — so the Desktop can prove completion across the dashboard
restart boundary instead of guessing from stale log text.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>

* fix(desktop): make remote backend updates terminal-state driven

Remote backend updates failed with "Backend update failed." on nearly
every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then
read exit_code null off the still-running action and called it a
failure. Real updates (backup + uv sync + npm install + vite build)
routinely run longer, and the no-op "Already up to date" path never
restarted the gateway so the old return-check timed out too.

A still-running, reachable action is now never converted into failure
by an elapsed budget — only a nonzero exit is. The apply loop keeps one
in-flight promise, tolerates reconnects during the dashboard restart
without extending the fixed six-minute deadline forever, and confirms
success by the action-specific receipt that survives the restart,
falling back to proving the requested commit / up-to-date check for
older backends without action_id support. Inconclusive completion
fails closed.

Fixes #47359
Fixes #58764

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: doncazper <caztronics@yahoo.com>

---------

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
2026-08-05 10:42:02 -06:00
brooklyn! 64646dda56
Hermes can read the in-app browser (#79482)
* feat(agent): read_preview — the desktop-gated tool that reads the in-app browser

The agent could open the preview pane (open_preview) and read the embedded
terminal (read_terminal), but the browser it had just opened was a black box —
'what does this page say?' had no answer. read_preview mirrors read_terminal
end to end: HERMES_DESKTOP-gated via check_fn (zero schema footprint outside
the GUI), dispatched through the same agent callback pattern, windowed with
start/count so a long page pages instead of flooding context.

* feat(gateway): preview.read blocking bridge

Same lifecycle as terminal.read: the tool blocks on preview.read.request, the
renderer answers preview.read.respond (allow_expired — a slow page extraction
losing the 45s race must not surface a raw 4009), and a timeout emits
preview.read.expire so late answers resolve quietly.

* feat(desktop): the renderer serializes the active preview tab for the agent

preview-reader.ts is the preview analog of the terminal's buffer registry: the
URL pane registers a page reader (webview executeJavaScript → title + visible
innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows
the text (24k cap per read), and answers file/artifact tabs with identity plus
a note pointing at the tool that reads that content directly. The gateway
event handler answers preview.read.request beside terminal.read.request.
2026-08-05 16:35:00 +00:00
brooklyn! 28a3fe5c33
Merge pull request #79507 from NousResearch/bb/remote-pdf-preview
fix(desktop): render remote PDFs in preview rail
2026-08-05 10:34:28 -06:00
Brooklyn Nicholson eb68ffbe43 fix(desktop): make remote backend updates terminal-state driven
Remote backend updates failed with "Backend update failed." on nearly
every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then
read exit_code null off the still-running action and called it a
failure. Real updates (backup + uv sync + npm install + vite build)
routinely run longer, and the no-op "Already up to date" path never
restarted the gateway so the old return-check timed out too.

A still-running, reachable action is now never converted into failure
by an elapsed budget — only a nonzero exit is. The apply loop keeps one
in-flight promise, tolerates reconnects during the dashboard restart
without extending the fixed six-minute deadline forever, and confirms
success by the action-specific receipt that survives the restart,
falling back to proving the requested commit / up-to-date check for
older backends without action_id support. Inconclusive completion
fails closed.

Fixes #47359
Fixes #58764

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
2026-08-05 10:34:18 -06:00
Brooklyn Nicholson 950b55d4d7 feat(update): emit an action-scoped terminal receipt from hermes update
The dashboard now mints an action_id per backend update, hands it to the
spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight
update action instead of spawning a duplicate. The updater prints a
bounded `=== hermes-update completed <id> ===` receipt on every success
path — normal, zip, dependency-repair, and the no-op "Already up to
date!" path that previously ended with no terminal marker at all
(#58764) — so the Desktop can prove completion across the dashboard
restart boundary instead of guessing from stale log text.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
2026-08-05 10:34:18 -06:00
hermes-seaeye[bot] 9e9b3fc669
fmt(js): `npm run fix` on merge (#79505)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 16:32:54 +00:00
brooklyn! 7e16241825
feat(wake): hands-free wake word for remote desktop via client mic streaming (#79491)
* feat(wake): client-capture wake word for remote desktop

Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.

- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)

* fix(wake): address review on client-capture re-arm and feed queue

- wake.status reports effective capture from the armed detector (client vs
  local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
  while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works

* fix(wake): auto capture keeps the backend mic when one exists

With capture:auto the desktop always preferred client streaming, so a local
desktop with a working backend mic silently switched from PortAudio to
getUserMedia default-device — dropping wake_word.input_device selection
(#74363). A ready backend input now wins under auto; client capture is the
fallback for a preferring surface on a mic-less backend, and capture:client
still forces streaming.

Also removes the dead auto branch (both arms returned local) and lets the
client-feed test skip cleanly when numpy is absent.

* perf(desktop): coalesce wake.feed frames

Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the
ear is armed. Drain up to 4 queued frames into a single wake.feed payload
(backend feed() already splits long buffers into engine frames) — ~3 RPCs/s
steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not
0.5 s).

* docs(config): document wake_word.capture in cli-config.yaml.example

---------

Co-authored-by: Andrew <drew@kainotomic.com>
2026-08-05 10:32:38 -06:00
Matt Prusak c8fdc51740 fix(desktop): render remote PDFs in preview rail
PDFs were classified as generic binary/text previews, rendering raw %PDF
bytes locally and failing entirely for remote-only files. Classify PDFs as
their own preview kind, load bytes through the existing local/remote
filesystem bridge, convert them to revocable Blob URLs for Chromium's
embedded viewer, migrate persisted pre-PDF tabs at restore, and retry
restored previews when the active filesystem connection changes.

Salvaged from #76008-era base onto current main: PDF classification now
composes with the remote-HTML enrichment branch, and the persisted-tab
migration runs before the One-Browser URL rekey in decodePreviewTabs.

Supersedes #76565.

Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh>
2026-08-05 10:26:48 -06:00
Brooklyn Nicholson c6fe31a9be test(desktop): expect client_capture in wake.start/status params
The GUI now passes client_capture: true on wake.start, wake.status, and the
post-voice re-arm; update the store and slash-handler tests to the new
param shape. 123/123 pass locally.
2026-08-05 10:25:57 -06:00
brooklyn! c8648278c3
In-app browser and previews are real layout-tree tabs (#77705)
* feat(desktop): shared pane-strip primitives — one bar, one glyph, one close menu

The zone header hand-rolled its tab bar, its close-verb context menu, and the
bare-glyph "+" inline; the preview rail kept a second copy of all three. Extract
PaneTabStrip (the bar), PaneStripGlyph/PaneStripTool (glyph buttons as data,
titlebar-tool style), and paneTabCloseItems (the four close verbs) into the
pane-tab primitives, and render the zone header through them. Panes contribute
strip glyphs via PaneChrome.stripTools; $stripToolsRevision tells the strip to
re-read.

* refactor(desktop): preview tabs are layout-tree tiles like session and page tiles

The in-app browser / preview rail carried its own tab strip beside the zone's
own — a second bar at a different height with its own close menu, label casing,
⌘W rung, and welded to the file browser's zone so ⌘J toggled it away. It
predated the layout tree.

$previewTabs now mirrors into pane contributions through the same paneMirror
session and route tiles use, so a preview tab IS a zone tab: one strip, drag/
stack/split, the shared close verbs, plain ⌘W, its own zone docked beside main.
URL tabs are titled Browser (the tab names the surface, not the page); files
keep their filename and a file-type lead glyph.

Deleted with the rail: the preview pane contribution + PREVIEW_PANE_ID + its
visibility binding, the 'preview' placements in the default tree and presets,
the ⌘W rail rung, the reveal listener, and the preview.close* i18n keys (copies
of zones.*). lone-header now keys on "closeable placement:main" instead of the
session-tile: id prefix, so any tile dragged into its own zone keeps its tab.

* fix(desktop): preview console/DevTools live on the strip, and DevTools tells the truth

The two toggles were titlebar tools — far from the preview they act on and one
ambiguous global pair once two previews were open. They're strip glyphs now,
contributed per-tab as PaneStripTool data with real tooltips: the console store
is cached by tab id so the glyph and the panel read the same logs, and the pane
registers a DevTools handle for its tab.

DevTools active state was also a lie: it tracked our click handler, so closing
the DevTools window itself left the glyph stuck on. The webview's
devtools-opened/closed events drive it now.

* fix(desktop): ⌘W and ⌃Tab work over preview and page zones

The generic tab verbs keyed zone eligibility on the CHAT strip (workspace /
session-tile: ids), so a zone holding only a Browser or page tile was invisible
to them: ⌃Tab skipped it, and ⌘W fell through the chat rung and emptied the
MAIN chat while you were looking at a preview. ⌘1…⌘9 already worked — the
verbs disagreed about what counts as a tab strip.

New isMainStripPane (any placement:'main' tenant — sessions, pages, previews)
drives ⌘W and ⌃Tab; isSessionStripPane keeps gating what it should: where a
session may dock (⌘T's anchor, the strip's +).

* fix(desktop): preview tab selection follows the tree, not just the reverse

openPreview drove tree reveals, but clicking a preview TAB only activated its
pane in the tree — $rightRailActiveTabId kept naming the previous tab, so
$previewTarget (⌘L quote labels, the titlebar's has-preview state) reported a
tab that wasn't on screen. The mirror now also listens tree→store: when the
interacted zone's active pane is a preview tile, the store selection follows.
Both directions converge on the same id, so no ping-pong.

* fix(desktop): session drags land in preview and page zones

tileZoneHost replaces chatZonePane: a zone hosting any main tile (a Browser
tile, a page) accepts stack and split drops — the known asymmetry where you
could drag a preview tab out but never drag a session in. Only a CHAT zone's
center is the link-to-composer drop; a preview zone's center stacks, since
there's no composer to link to.

* chore(desktop): drop the rail's dead multi-close verbs

closeActiveRightRailTab / closeOtherRightRailTabs / closeRightRailTabsToRight
lost their last callers when ⌘W and the close menu moved to the zone strip's
shared rungs; the tests now exercise closeRightRailTab's own fallback
behavior directly.

* fix(desktop): open_preview lands whenever its session is on screen

The preview.open handler honored the event only when its session was the
FOCUSED one — but the turn that runs open_preview is usually a tile's session,
and by the time the tool fires the user's last click has often parked focus on
main (or anywhere else). The tool reported success, the store never wrote, and
nothing appeared: an explicit 'open reddit' silently vanished.

On-screen is the right bar: honor the open when the session is the primary
chat or any open tile, which keeps truly invisible background sessions from
yanking the pane (offer, don't hijack) without eating opens the user asked
for.

* fix(desktop): one Browser — a second URL navigates it, not a second tab

Tabs were keyed url:<address>, so every distinct page the agent opened
stacked another BROWSER tab — three opens, three Browsers, each titled
identically because the tab deliberately names the surface, not the page.
The title already said singleton; the key disagreed.

URL targets now share one url:browser id: openPreview re-fronts the tab and
swaps its target, and the pane rebuilds its webview against the new address.
Files and artifacts keep per-identity tabs. Restored storage rekeys old
per-address rows and keeps only the most recent.
2026-08-05 16:22:58 +00:00
brooklyn! 4aeffb89c7
fix(desktop): open remote file rows in the in-app preview (#79494)
A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.

Supersedes #70296 and #57878.

Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
2026-08-05 10:17:03 -06:00
hermes-seaeye[bot] 18ed612e5c
fmt(js): `npm run fix` on merge (#79496)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-05 16:16:43 +00:00
Brooklyn Nicholson 7c33b806a1 chore: fix import order, map contributor email
- perfectionist/sort-imports in store/wake-word.ts
- contributors/emails mapping for drew@kainotomic.com -> appletechie
2026-08-05 10:12:31 -06:00
Brooklyn Nicholson 17a5a95871 fix(desktop): open remote file rows in the in-app preview
A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.

Supersedes #70296 and #57878.

Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
2026-08-05 10:07:53 -06:00
Bear Huddleston 069551d19b
fix(desktop): preview remote HTML over SSH (#76008)
* fix(desktop): preview remote HTML over SSH

* fix(desktop): harden remote HTML sanitization
2026-08-05 10:07:35 -06:00
Brooklyn Nicholson dfc1cbebbd docs(config): document wake_word.capture in cli-config.yaml.example 2026-08-05 10:06:49 -06:00
Brooklyn Nicholson 6df3912e0a perf(desktop): coalesce wake.feed frames
Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the
ear is armed. Drain up to 4 queued frames into a single wake.feed payload
(backend feed() already splits long buffers into engine frames) — ~3 RPCs/s
steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not
0.5 s).
2026-08-05 10:06:49 -06:00
Brooklyn Nicholson 60808dcf72 fix(wake): auto capture keeps the backend mic when one exists
With capture:auto the desktop always preferred client streaming, so a local
desktop with a working backend mic silently switched from PortAudio to
getUserMedia default-device — dropping wake_word.input_device selection
(#74363). A ready backend input now wins under auto; client capture is the
fallback for a preferring surface on a mic-less backend, and capture:client
still forces streaming.

Also removes the dead auto branch (both arms returned local) and lets the
client-feed test skip cleanly when numpy is absent.
2026-08-05 10:06:49 -06:00
Andrew d401c27edf fix(wake): address review on client-capture re-arm and feed queue
- wake.status reports effective capture from the armed detector (client vs
  local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
  while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works
2026-08-05 10:03:55 -06:00
Andrew 105fbf6b7d feat(wake): client-capture wake word for remote desktop
Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.

- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)
2026-08-05 10:03:48 -06:00
kshitij cc245e84d2 fix: correct cron mid-run restart claim in salvaged docs
The original PR #78453 said 'A job that was mid-run during a restart
resumes according to the attempt policy described in this page.' This
is misleading — the existing docs explicitly state 'Unknown attempts
are audit records and are never automatically rerun.' Corrected to
accurately describe: the mid-run attempt is marked unknown (not retried),
but the job's next scheduled tick fires normally.
2026-08-05 21:33:44 +05:30
witcheer 2183ed3921 docs: fix stale PATH location in windows-native common pitfalls 2026-08-05 21:33:44 +05:30
witcheer 7ce6f97945 docs: explain the slow silent first turn (prefill) on local hardware 2026-08-05 21:33:44 +05:30
witcheer f8aed15cb1 docs: warn against pointing two agents at one Hermes home (memory, profiles, FAQ) 2026-08-05 21:33:44 +05:30
witcheer a5ab9b2e5a docs: add troubleshooting checklist for perceived agent-quality regressions 2026-08-05 21:33:44 +05:30
witcheer 8618eba7c8 docs: add security-posture guide for running Hermes on a personal or work machine 2026-08-05 21:33:44 +05:30
witcheer 6cd0aca48c docs: surface existing answers users can't find (migration, prompt-size, tool-call parsing, Desktop label) 2026-08-05 21:33:44 +05:30
witcheer e20cfd35e0 docs: four small accuracy fixes
- cron: state explicitly that job definitions survive updates, gateway
  restarts and reboots (asked directly in #37542)
- mcp: add a Claude Code bridge tip - mcpServers maps to mcp_servers and
  hermes import-agent migrates it (the MCP page never says 'mcpServers'
  in the client direction; arrivals from Claude Code get no pointer)
- installation: surface loginctl enable-linger in the non-sudo/service
  user section where affected users start (currently only on the
  gateway page; #43748)
- sessions: document optimize, optimize-storage, repair, recover and
  retitle-skills in the CLI reference (shipped in v0.19.1 --help but
  absent from the table) and recommend non-destructive optimize before
  prune in the db-growth tip

All wording verified against hermes v0.19.1 --help output and the live
pages on 2026-08-04.
2026-08-05 21:33:44 +05:30
witcheer 8cc4ff249e docs: add per-plan subscription billing table to providers page
Users with Claude Pro/Max, ChatGPT/Codex, SuperGrok or Gemini plans
cannot find what their plan pays for in Hermes in one place (e.g.
#15291, #27228). One comparison table + per-provider notes; cells the
docs do not yet specify are marked 'not currently documented' rather
than guessed.
2026-08-05 21:33:44 +05:30