Commit Graph

2962 Commits

Author SHA1 Message Date
hermes-seaeye[bot] 046a868b7f fmt(js): npm run fix on merge (#88321)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 10:09:48 +00:00
Teknium 2c0035d3b4 fix(desktop): bound the pre-start settle hold so a restore/edit arm can't latch the composer shut
The no-payload settle gate in gateway-event.ts held session.info
running=false off unconditionally while an optimistically armed turn
(busy/awaitingResponse from restore/edit/submit) had not gone live
backend-side. When the turn never went live at all — a rewind refused
after the optimistic arm, a submit response lost to a gateway bounce, a
terminal error event that never arrived — busy latched forever:
isTargetSessionBusy refused every send, the composer queued each message
('moves to the send area'), and the queue drain (gated on busy→false)
never fired. Only an app restart cleared it (#86795).

Bound the hold to PRE_TURN_LIVE_SETTLE_GRACE_MS (15s) measured from
turnStartedAt; past the window (or with no clock) the gateway's
running=false is authoritative and settles the session. Seed the clock +
reset turnLive in applyRewindOptimistic/applyReloadOptimistic (the
restore/edit/regenerate arm sites), and clear both on every rewind
rollback path in use-prompt-actions and session-tile-actions so a failed
rewind can't leave a stale seed.

Fixes #86795
2026-08-17 03:04:00 -07:00
Adam Durham f1dd8d32a8 test(desktop): extract ProfileRail focus/visibilitychange wiring into a tested hook
Addresses review feedback from the hermes-sweeper (salvageability=high,
keep_open): "The new focus/visibility listener behavior lacks a runtime
UI regression test... no ProfileRail test."

Rendering the full ProfileRail component for this would drag in
drag-and-drop, dialogs, hotkeys, and i18n unrelated to what needs testing.
Instead, extracted the focus/visibilitychange wiring into its own
use-profile-rail-refresh-on-active hook, matching this exact directory's
own established convention (use-profile-prewarm.ts is the same shape:
a small side-effect hook pulled out of ProfileRail specifically so it's
unit-testable in isolation).

Added 6 tests covering exactly what the review asked for: refresh on
mount, refresh on window focus, refresh on visibilitychange while
visible, NO refresh on visibilitychange while hidden, listener cleanup
on unmount, and no listener accumulation across repeated mount/unmount
cycles.

Verified the tests have real teeth: simulated the exact bug this PR
originally fixed (dropped the cleanup return, leaving listeners attached
after unmount) and confirmed 4 of 6 tests correctly fail against it --
including "no accumulate listeners" showing 7 calls instead of 1, the
exact leaked-listener signature. Restored the real fix and all 6 pass.

ProfileRail itself is otherwise unchanged in behavior -- this is a pure
extraction (same effect, same dependencies, same cleanup), not a
behavior change. Full sidebar test suite: 93 passed across 12 files (up
from 87 across 11), 0 regressions. Python side unaffected: 158 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-17 02:11:27 -07:00
Adam Durham 19c68e8c96 fix(desktop): work profile deletion silently reverted after app restart
Two independent bugs let a deleted profile reappear / leave orphaned
resources on next launch:

1. hermes_cli/profiles.py's backend-process scanner required argv[0] to
   resolve to an executable literally named "hermes". Electron's
   pool-backend spawn resolves the hermes console-script shim's path and
   execs it via the interpreter directly (python3 /path/to/hermes ...), so
   argv[0] reports as "python3" and the scanner never matched the running
   backend -- delete removed the profile's files but left its live backend
   process running (still bound to a port via uvicorn), which
   accumulates across repeated delete/recreate cycles.
2. The desktop sidebar's ProfileRail only refreshed its cached profile
   list once, on mount, so a delete/create/rename from another surface
   (another window, or the CLI) left a stale ghost entry until something
   unrelated triggered a refetch. Note: a delete via this window's own
   Manage-Profiles view already refreshes the shared $profiles atom
   ProfileRail subscribes to (confirmed by reading refreshProfiles() and
   handleConfirmDelete()) -- this fix only covers the cross-window/cross-
   process staleness gap, not a duplicate of the already-merged
   #57329's Manage-Profiles rail-refresh work.

Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named
console-script shim via argv[1]. Fix 2: refresh the profile list on window
focus/visibilitychange, matching the existing pattern used elsewhere in
the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx,
use-gateway-boot.ts all use the same focus+visibilitychange pattern).

## Related work already on main

PR #57329 (merged) fixed the *headline* symptom from issue #52279
(deleted profile respawns) via a different, non-overlapping mechanism:
routing profile-delete through the primary backend instead of spawning a
fresh pool backend, plus a separate recreation guard in
ensure_hermes_home() (#49435, merged) that makes a backend spawned into a
deleted profile's directory raise FileNotFoundError instead of silently
recreating it.

This PR is NOT a duplicate of that fix. Verified: even with both of those
merged, a backend process that survives because of gap #1 above still
holds a bound port via uvicorn -- it just can no longer resurrect the
profile directory. That's real resource-hygiene, not a symptom already
covered. Gap #2 touches a different file/component (ProfileRail /
profile-switcher.tsx) than #57329's rail-refresh half (which touched the
Manage-Profiles view's own $profiles.ts / index.tsx) and covers a
distinct staleness path (cross-window/cross-process, not same-window
delete-then-refresh).

Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing +
regression coverage for the argv[0] python-interpreter detection case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-17 02:11:27 -07:00
Teknium 6cca9f3e71 fix(desktop): validate SSH host input and redact credentials in ssh target logging
A user typed their root password into the Desktop SSH host field
(root@IP:PASSWORD form). Three failures compounded:

1. validateSshTarget() only checked for option injection (leading dash),
   control chars, and port range — commas in an IP, whitespace ("ssh "
   prefix pastes), and non-numeric ":<segment>" leftovers all dialed ssh
   with garbage and failed silently five times.
2. normalizeSshConfig() only strips a ":<segment>" when it is numeric, so
   a pasted password stayed glued to the hostname all the way into ssh
   argv and the desktop.log connect line.
3. redactSecrets() had no pattern for ssh targets, so the password landed
   verbatim in desktop.log and then in a PUBLIC debug-share paste.

Changes:
- validateSshTarget(): reject whitespace, commas, non-numeric colon
  segments (with a "never put a password in the host field" hint that
  does NOT echo the credential), and garbage hostnames; still accepts
  bare IPv6 (::1, fe80::1%eth0). Reject whitespace/@ in user.
- redactSecrets(): new pattern masks any non-numeric segment where a
  port belongs in user@host:... strings — defense in depth so future
  parse gaps can't leak credentials into logs or debug shares.
- normalizeSshConfig(): strip a pasted leading "ssh " prefix.
- Tests for all three, including the exact incident shapes.
2026-08-17 01:54:41 -07:00
Teknium 1a74e7fb43 feat(desktop): offer Bot Mode agent handles in the composer @ autocomplete
The @ popover only completed filesystem references; bot handles worked
when fully typed (mention middleware parses at submit) but were never
offered, so users had to know the exact handle — worse with multi-source
@name-device handles. Fixes #88060 (ported from Hermes-Bot-Mode#43).

- composer contrib: new 'composer.atCompletions' data area
  (ComposerAtCompletionSource) — contributed rows merge AHEAD of path
  results; a throwing source drops its rows, never the popover
- use-at-completions: merge contributed entries in all three fetch paths
  (gateway results, gateway-less, fetch error)
- SDK: export the new area + types for plugins
- bundled Bot Mode plugin: registers 'mention-completions' — roster
  handles from the query cache (\u22645s stale), active profile excluded,
  'default' offered as @hermes, multi-source @name-device handles via
  botHandle, display name + connection label in the row meta, capped at 8
- registered early in register(ctx) so vm harnesses reach it before the
  pane/UI registrations that stubs can't fully model
2026-08-17 01:43:04 -07:00
Teknium 72785b2657 feat(desktop): Discord-style group chat creation in Bot Mode
The Bots pane header + is now a dropdown (New Agent / New Group Chat).
New Group Chat opens a checkbox-picker modal: searchable roster list,
member cap at GROUP_CHAT_MAX_MEMBERS, group-name input that defaults to
the selected members' names, and a Create button that assigns the
existing per-bot group meta field - so the room rides the ui_meta sync
path unchanged and the user lands directly in the new room.
2026-08-17 01:36:03 -07:00
hermes-seaeye[bot] cecb3a6ed7 fmt(js): npm run fix on merge (#88206)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 06:37:33 +00:00
Gille 2038d4034d fix(desktop): prevent deleted profile respawn 2026-08-16 23:31:12 -07:00
Teknium 9adc900ab2 fix(bot-mode): route bot-to-bot sends through --create-if-missing
The teammate-messaging protocol told bots to send with
`chat -c "Bot Chat" -Q -q` and, on 'No session found', fall back to a
manual two-step (send without -c, then sessions rename) — a dance the
CLI already made unnecessary when --create-if-missing landed (#86794).
Profiles that never went through the Bots-panel birth flow (CLI-created,
pre-Bot-Mode, remote-source) hit that miss on every first contact, and
background sends swallowed the error entirely (the original silent-drop
in Hermes-Bot-Mode#48 / #88059).

- tools/bot_mode_probe.py: protocol command gains --create-if-missing
- bundled plugin: Bot Chat prompt section + @mention handoff note use
  the flag; rename-dance instructions deleted

The capability epoch hashes the protocol section, so existing eternal
Bot Chat sessions pick the new instructions up on their next message via
the established once-per-change rebuild — no per-turn cache drift.

Live-verified: fresh profile with zero sessions, protocol command
created 'Bot Chat' and delivered (PONG round-trip); second send resolved
the same session by title (no duplicate); missing-title send WITHOUT the
flag still errors loudly on stderr.
2026-08-16 23:28:13 -07:00
pierrenode 93ed11379b fix(desktop): recover the tile and regenerate paths from a dead runtime id
after a stale runtime-session drop (the sleep/wake 404 that resumes the
stored session and retries once), but two call sites still build their
gateway call directly instead of routing through withSessionNotFoundResume:

- session-tile-actions.ts's own cancelRun/steerPrompt/reloadFromMessage —
  the tile's OWN UI handlers (wired directly by session-tile.tsx as
  onCancel/onSteer/onReload), distinct from use-session-tile-delegate.ts's
  interruptSession/submitToSession (used by external callers like
  quick-entry-bridge), which #81261 did wrap.
- use-prompt-actions/index.ts's reloadFromMessage (the primary chat's own
  "Regenerate") — it builds its prompt.submit call inline instead of going
  through the shared send() helper every other action in this file uses,
  so it never picked up the recovery wrapper.

After sleep/wake (the exact scenario #81261 targets), clicking Stop,
sending a steering correction, or clicking Regenerate on a tile or the
primary chat surfaces a raw "session not found" error instead of silently
resuming, even though #81261 landed the day before.

Wrap all four call sites in withSessionNotFoundResume, mirroring the
existing pattern each file already uses elsewhere (submitRewind/
syncAttachmentsForSubmit in session-tile-actions.ts, redirectPrompt/send in
index.ts) — resolve the stored session, resume once, retry, and rebind the
live runtime ref via onRecovered.
2026-08-16 22:24:31 -07:00
SHL0MS 243352e7b8 fix(desktop): unbind session tiles from a reclaimed runtime so they self-recover
A tab/tile whose live runtime the backend reclaims (ws_orphan_reap,
idle_timeout, lru_evict) rendered an empty transcript under healthy
chrome, permanently: session.reclaimed dropped the runtime's cached
state but left the tile bound to the dead runtime id, and the tile's
resume effect is gated on !runtimeId so it never refired. Sidebar
re-click could not recover it; only close-tab or an app restart did
(tile persistence strips runtime ids, which is why a remount healed).

The reconnect-path resetTileRuntimeBindings() cannot cover this case:
the WS re-dials immediately while the orphan reaper fires a grace
window later, so the reclaim always lands after that unbind ran.

On session.reclaimed, unbind whichever tile holds the reclaimed
runtime (new unbindTileRuntime, the targeted sibling of
resetTileRuntimeBindings) so the existing resume effect refires
against the intact stored session, and purge the wiring cache's entry
so resumeTile's warm path can't hand the dead runtime straight back.

Live-reproduced both ways on an isolated dev instance (20s reap
grace): pre-fix the tile stays bound to the dead runtime with its
state gone (blank pane); post-fix it sheds the binding and repaints
the transcript within seconds, across two consecutive reap cycles.

Fixes #82620
2026-08-16 22:22:04 -07:00
Teknium 5099a65a05 fix(desktop): re-resume session tiles with live runtime ids after sleep/wake
After sleep/wake the gateway reconnect path called resetTileRuntimeBindings()
to force every tile to re-resume, but it only cleared the tile atoms'
runtimeId. resumeTile()'s warm path then re-bound each tile from the wiring
cache's stored->runtime map - the same dead pre-sleep runtime id, with a
released (empty) cached transcript. Result: every split pane except the
primary repainted as an empty pane with only its header, and prompts
submitted to it recovered into the primary view instead.

- resetTileRuntimeBindings() now also invalidates the delegate's wiring
  cache (new optional SessionTileDelegate.invalidateRuntimeBindings, backed
  by runtimeIdByStoredSessionIdRef.clear()) so post-reconnect resumes go
  cold and bind a live runtime id.
- resumeTile()'s warm path now requires the cached state to carry a
  transcript (or be mid-turn): a released/stale empty state goes through
  to a real session.resume + transcript hydration instead of repainting
  an empty tile.

Regression tests fail without the fix (verified via sabotage run) and pass
with it; tsc + eslint clean.
2026-08-16 22:16:41 -07:00
Teknium 0b13cafffa feat(cron): continuity toggle across dashboard, Bot Mode routines, and TUI cron RPC
Wire the continuity flag through every cron-creation surface, not just the
model tool:

- dashboard (web/): checkbox in the cron job editor; form state round-trips
  the stored reserved 'self' entry into the toggle and strips it from the
  context_from textarea; web_server dashboard validator skips 'self'
  (create precedes the job's existence)
- Bot Mode Routines tab (hermes-bots plugin): Continuity checkbox in the
  New Cronjob dialog, forwarded through cron.manage
- tui_gateway cron.manage RPC: optional continuity param on action=add

vitest cron-job suite 10/10 (4 new), tsc app project clean, py_compile clean.
2026-08-16 22:09:28 -07:00
teknium1 25851e6e58 fix: resolve semantic merge conflict — startHermes update-wait moved into runPrimaryBackendStartup (waitForLocalStart); drop duplicated waitForUpdateToFinish call, keep login-shell PATH merge before backend resolve 2026-08-16 22:05:51 -07:00
Teknium c13105a8c5 Port from cline/cline#12482 era: login-shell PATH resolution for GUI-launched desktop (cline/cline#12429)
feat(desktop): resolve the user's login-shell PATH once at startup and
merge it into process.env before the backend spawns.

GUI launches (Finder/Dock on macOS, desktop launchers on Linux) inherit
a minimal PATH that never runs the user's shell profiles, so the
backend process — and everything it spawns or probes (shutil.which
availability checks like cua-driver, stdio MCP servers, Electron-side
git/gh/hermes resolvers) — cannot see Homebrew-, nvm-, pyenv-, cargo-,
or ~/.local/bin-installed tools. backend-env.ts's static sane-entry
list covers Homebrew//usr/local but not profile-added dirs.

Approach (ported from cline/cline#12429, mirrors VS Code's shell
environment resolution):
- new electron/shell-path.ts: run $SHELL -ilc (fallback -lc for the
  macOS system-bash-3.2 swallow) printing $PATH between sentinel
  markers so profile banners can't corrupt the capture
- merge login-shell entries first, current-only entries appended,
  deduped via backend-env's appendUniquePathEntries
- single-flight, timeout-bounded, failure-hardened: a broken or slow
  shell profile never blocks boot; win32 no-op
- warmed at app.whenReady, awaited before backend runtime resolution

12 unit tests + live E2E verified (GUI-minimal PATH enriched with
~/.local/bin, nvm, cargo, go entries on a real shell).
2026-08-16 22:05:51 -07:00
hermes-seaeye[bot] 1826310f49 fmt(js): npm run fix on merge (#88128)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 04:19:24 +00:00
Teknium f8f43c9523 fix(desktop): make git worktrees work end-to-end on a remote gateway backend
Cmd/Ctrl+Shift+B worktree flows on a remote gateway route through the
backend's /api/git mirror (hermes_cli/web_git.py), but that mirror had
drifted behind the Electron-local git ops the same UI drives locally, so
the flows broke exactly and only on remote connections:

- Convert-a-branch: the picker offers remote-tracking refs, and the
  Electron op turns "origin/feature" into a local tracking branch. The
  mirror ran `git worktree add <dir> origin/feature` verbatim, which
  either fails or detaches HEAD. It now resolves the ref's remote via
  git (never assuming "origin"), fetches best-effort, and creates the
  worktree with `--track -b <short-name>`.
- branch_list omitted remote-tracking refs entirely and never set the
  `isRemote` flag the renderer's HermesGitBranch contract requires —
  the convert picker on a remote gateway couldn't reach a teammate's
  branch and mislabeled every row's action.
- Branching off an `origin/…` base silently wired the new branch to the
  remote upstream; the mirror now passes `--no-track` like the Electron
  op does.

Renderer side, replace the silent degradation with a capability gate:
when a remote backend predates the /api/git worktree routes, worktree
creation failed with an opaque "Expected JSON … got HTML" toast. The
route-missing shapes now surface a clear "update the Hermes backend"
message (isGitEndpointMissingError, mirroring the sidebar batch-endpoint
detector); real git errors still pass through untouched.

Sibling audit (documented, no code change needed): repo status / review /
file-diff / git-root / default-cwd already route through desktopGit()'s
REST bridge or /api/fs on remote; repo scan is deliberately a no-op there.
Stale comments claiming "empty/false on a remote backend" in projects.ts
and coding-status.ts updated to describe the backend-routed reality.

Fixes #81724
2026-08-16 20:53:44 -07:00
Teknium a36583e311 fix(desktop): keep cloud bot avatar eye catchlights inside the eyes
The white catchlight dots in BotFace were static circles pinned at the
circle-face eye line (cy 16.5), while the animation clock moves the
pupils to the shape-aware eye line (cy 22 for the cloud). On the cloud
avatar the highlights floated above the eyes instead of inside them.

- Tag the catchlights (data-hb-hl-l/r) and move them with the pupils in
  paintMathFace, offset upper-left of each pupil center.
- Render the initial eyes/catchlights/shut-lids at the shape-aware eye
  line so the first frame matches the animated frames.
2026-08-16 20:51:58 -07:00
Teknium b711fd0513 feat(desktop): carry remote gateway headers through the connections registry, test probes, and Settings UI
Completes PR #74468 (remote gateway headers for Cloudflare Access, #74466)
against the v2 multi-connection registry that landed after the PR was
authored, and closes the review blockers:

- connection-registry: additive optional `headers` field on remote/cloud
  entries (normalized through the same forbidden-name filter, secret
  envelopes like `token`); inherited on edit, treated as dial material by
  connectionDialFieldsChanged, preserved by normalizeRegistry, and carried
  through migrateV1ToRegistry. v2 registries without the field load
  unchanged — no version bump.
- main.ts registry paths: connectRegistryBackend dials with the entry's
  headers (readiness probe, ticket mint, descriptor REST via
  getJsonForBackend/fetchJsonForBackend, registry ws-url minting with
  rememberRemoteWsHeaders so renderer upgrades get them injected).
- saveRegistryConnection encrypts incoming plaintext header values with the
  same safeStorage/allowPlainText seam as tokens; sanitizeRegistryConnection
  exposes only header NAMES to the renderer — values never cross IPC.
- Connection tests exercise the leg they validate: both
  hermes:connection-config:test and hermes:connections:test now send the
  configured headers on the HTTP status call, the ws-ticket mint, AND the
  live WebSocket probe (probeGatewayWebSocket grew an injectable `headers`
  option passed as the undici WebSocket constructor's second argument).
- Settings → Connections gains an "Extra gateway headers" editor for
  remote/cloud entries (name + secret value rows, stored values shown as
  saved-but-hidden, clearable), with i18n keys (en + zh; other locales fall
  back through defineLocale).
2026-08-16 19:58:49 -07:00
tigercraft4 fcef62ef72 feat(desktop): support remote gateway headers 2026-08-16 19:58:49 -07:00
Teknium 2b7f49673f fix(desktop): self-heal dropped SSH/HTTP registered remote connections
A dropped registered remote connection (SSH or HTTP) never recovered on
its own: the next boot attempt failed with a transient transport error
("Could not verify the existing SSH backend", ERR_CONNECTION_RESET,
mint timeout), the failure was correctly NOT latched, but nothing ever
re-attempted the boot — the renderer's reconnect machinery only arms
after a completed boot. The app parked on "Desktop boot failed" until
the user manually deleted and re-entered the same connection details,
which merely forced the fresh bootstrap an automatic retry would have
performed (issue 82679, feature ask 80430).

Root causes and fixes:

- electron/backend-start-failure.ts: new isRetryableRemoteBootFailure()
  predicate — a remote, non-reauth boot failure is transient and may be
  retried; local failures and confirmed 401/403 rejections are not
  (a missing capability differs from a transient failure).
- electron/main.ts: the boot-failure progress broadcast now carries
  `retryable` (rides with `error` through updateBootProgress), and a
  failed reuse probe against a cached SSH master tears the stale
  master/tunnel down so the next attempt bootstraps fresh — exactly
  what manual re-entry did.
- use-gateway-boot.ts: bounded self-heal loop for a failed boot whose
  progress is marked retryable — up to 5 re-attempts with the same
  full-jitter backoff as the socket reconnect loop (2s base, 15s cap).
  Exhausted retries end in the real boot-failure recovery overlay,
  never an infinite spinner. Reset on success and on soft switch;
  timer cleared on unmount.
- store/boot.ts: resumeDesktopBootForRetry() re-arms the overlay with a
  retry status while an automatic retry is in flight.

Secondaries already had full-jitter backoff (store/gateway.ts); this
closes the same class for the PRIMARY/registered-connection path.

Tests: predicate matrix (retryable vs reauth-latch mutually exclusive),
plus renderer hook tests proving a transient SSH failure self-heals on
the next attempt, retries are bounded (6 total dials then the recovery
overlay, no further attempts), and non-retryable failures never enter
the loop. Sabotage-verified (disabling either half fails 4 tests).

Fixes #82679
Fixes #80430
2026-08-16 19:58:37 -07:00
fangliquanflq ace85d63de feat(desktop): add status bar reconnect for offline gateways
Expose the existing profile-aware gateway boot reconnect path through a
single-flight renderer action, and surface a Reconnect button in the
gateway status menu panel whenever the socket is not open. Repeated
clicks share one in-flight reconnect; failures surface through the
existing non-destructive notification UI. Localized copy for all
supported Desktop locales.

Salvaged from PR #80694 (net diff re-applied onto current main; panel
code lives in app/shell/gateway-menu-panel.tsx now).
2026-08-16 19:58:37 -07:00
hermes-seaeye[bot] f0ab10455a fmt(js): npm run fix on merge (#88079)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 02:34:46 +00:00
Jesse Panganiban 307d46c207 fix(desktop): map SSH profile aliases in REST paths 2026-08-16 19:28:56 -07:00
Teknium 8236b41771 feat: sync bundled Bot Mode with multi-source roster (Hermes-Bot-Mode#68)
Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.

Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
  landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
  signature

Plugin suite: 143/143.
2026-08-16 18:30:53 -07:00
Teknium 400400e1d9 fix(hermes-bots): composeSoul honors the bot_mode_protocol capability
Found in live desktop E2E: the generated-identity path of composeSoul
still appended the protocol section even when the backend injects it
into the system prompt. New agents now get a clean identity-only SOUL
against capable backends; older gateways keep the append. Covered in
the capability-suppression test.
2026-08-16 18:30:53 -07:00
Teknium d516496cef fix: track bundled plugin.js sources past the tsc-artifact gitignore
apps/desktop/src/**/*.js is gitignored (stale tsc output shadows .tsx),
which silently dropped the hermes-bots plugin.js from the adoption
commit — tests shipped, source didn't, CI ENOENT'd. Negate the pattern
for src/plugins/*/plugin.js: adopted plain-ESM plugins have no .tsx
sibling, so the shadow hazard cannot apply.
2026-08-16 18:30:53 -07:00
Teknium 366d8814b8 feat(desktop): bundle Bot Mode (hermes-bots) as a built-in, default-on plugin
Adopts the Hermes-Bot-Mode desktop plugin (NousResearch/Hermes-Bot-Mode)
into apps/desktop/src/plugins/hermes-bots/, registered by the bundled
vite glob and ON by default. It stays a pure @hermes/plugin-sdk consumer
in plain-ESM plugin.js form; users disable it live in Settings > Plugins.

- contrib/plugins.ts: bundled glob accepts plugin.js entries
- contrib/runtime-loader.ts: a disk/runtime copy of an id that ships
  bundled is skipped (standalone installs predating adoption cannot
  double-register)
- package.json: check:test:plugins runs the plugin's node:test suite in
  CI (138 tests)
- source: Hermes-Bot-Mode @ c19baba, incl. today's #107/#103/#99 merges
2026-08-16 18:30:53 -07:00
hermes-seaeye[bot] 3c108589fb fmt(js): npm run fix on merge (#88016)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 23:43:53 +00:00
Brooklyn Nicholson d8f40d4176 test(desktop): steer suite drives the real redirectPrompt path; hydration fixture carries durable row shape
The live suite previously called appendMidTurnUserMessage directly, leaving
redirectPrompt's appendAfterActiveReply guard — the production decision of
WHERE a correction lands — outside the harness. Both hooks now mount together
sharing one state map, exactly as the desktop wires them, so a regression in
the caller (not just the insert) goes red. Verified by mutation: disabling the
guard fails 2/4.

Also covers the rejected-redirect path: a not_running response discards the
optimistic bubble instead of stranding a correction the model never saw.

The hydration fixture now carries the durable row shape the client actually
receives (row_id, reasoning, provider call_id/response_item_id on tool_calls)
instead of a hand-simplified echo, so the 'mirrors real state.db rows' claim
is honest. The fake-timer steer id counter is gone with the local insert —
ids come from redirectPrompt itself.
2026-08-16 18:38:34 -05:00
Brooklyn Nicholson f1f694aac1 test(desktop): harden steer-order suite against fake-timer id collisions
Review follow-ups: steer ids now come from a monotonic counter instead of
Date.now() (frozen under fake timers — two steers without a clock advance
would have collided), and the settle-above assertion documents its
load-bearing sealed-bubble assumption.
2026-08-16 18:38:34 -05:00
Brooklyn Nicholson b2f345f540 test(desktop): pin steered-turn transcript order end-to-end
A steered turn's contract — pre-steer output above the correction bubble,
post-steer output and the settled reply below it — was fixed across
several PRs (#73793/#83151 class, settle fixes) but only covered piecewise:
the mid-turn insert as a unit, the settle math as a unit. Nothing drove the
real stream reducer through a whole steered turn, and nothing asserted the
durable-row hydration renders the same order after reload.

Two suites close that:
- steer-arrival-order: full event sequences through useMessageStream's real
  handler + the real optimistic insert — single steer with tool activity,
  steer racing message.complete, double steer in one turn.
- steered-turn-hydration-order: toChatMessages over persisted row shapes
  copied from a real state.db steered turn, including a tool result that
  lands after the correction row.
2026-08-16 18:38:34 -05:00
hermes-seaeye[bot] d378a25e85 fmt(js): npm run fix on merge (#88014)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 23:33:14 +00:00
Teknium 4ee87f76ee fix(desktop): read cron run-history from the owning gateway
When Hermes Desktop works against a REGISTERED gateway connection, cron
jobs execute on that gateway and persist their run sessions in the
gateway's state.db. But every REST call in the app — the cron surface
included — carried only `profile`, so `hermes:api` routed it through the
local profile pool and `_list_cron_job_runs_sync` read a local state.db
with zero `source='cron'` rows. Every job showed "No runs yet" while the
same endpoint on the gateway returned the real runs (#87882).

Fix at the routing seam:

- HermesApiRequest gains an optional `connectionId`. The renderer's cron
  helpers (list/get/runs/delivery-targets/create/update/pause/resume/
  trigger/delete/blueprints) now tag the active registry connection via a
  new connectionScoped() twin of profileScoped(), fed from the same
  setApiRequestConnection seam store/gateway already maintains for the
  plugin socket.
- The hermes:api main-process handler resolves a tagged request through
  ensureRegistryBackend — the SAME pool the job list and WS traffic use —
  instead of the legacy profile route. Shared remote/cloud hosts (one
  gateway, many profiles) get the path scoped with ?profile= via the new
  pathWithProfileScope helper, factored out of pathWithGlobalRemoteProfile.
- '' / 'local' / absent connectionId keep the byte-identical v1 route, so
  single-source and connection-config-remote users are unaffected.

This covers the run-history panel, the sidebar cron peek, and every other
cron surface in one place, since they all funnel through the same helpers.

Fixes #87882
2026-08-16 16:27:16 -07:00
Teknium 5f8d488830 fix(desktop): keep profile rail alive across remote/Cloud connection switches
A connection/mode apply (soft re-home) moves /api/profiles routing to a new
backend, but nothing deterministically re-fetched the rail's $profiles list
and a stale in-flight response from the previous backend could land last and
collapse the rail to Home (#85731).

- store/profile: epoch-guard refreshProfiles/refreshActiveProfile so a
  response fetched against the previous backend never writes the shared cache
  (invalidateProfileListFetches), and bump the epoch on live profile swaps.
- store/gateway-switch: strand in-flight profile-list fetches in the same
  wipe every connection/mode apply funnels through.
- use-gateway-boot: explicitly re-pull the active profile + list from the NEW
  backend during softSwitch, best-effort like its sibling fetches.

Fixes #85731
2026-08-16 16:27:11 -07:00
Teknium afe238ac7e fix(desktop): scope session/pin lists per connection across windows
Multiple Desktop windows share one renderer origin (one localStorage
area) while each window can be connected to a DIFFERENT gateway. The
sidebar pin set (hermes.desktop.pinnedSessions), the manual session
order, and the remembered last-session/route navigation keys were all
persisted under single global (or profile-only) keys, so two windows on
different gateways read and reconciled the same lists: pin-sync's
pullRemotePins() in one window adopted/dropped pins belonging to the
other window's backend, producing the overlapping mixed PINNED/SESSIONS
lists reported after the v0.19.1 update relaunch.

Introduce a connection-scope persistence layer (connectionScopedAtom in
src/lib/connection-scoped.ts): the local connection keeps the bare
legacy key (byte-identical for single-backend users, same contract as
backendScopeKey), while remote connections persist under
`<key>.remote.<encoded baseUrl>.<encoded profile>` — the shape
workspaceCwdKey already established. setConnection() rescopes every
scoped atom when the window's connection changes (null descriptors keep
the current scope, as with syncCronModelImpactConnection), and pin-sync
resets its mirrored/pending/unconfirmed bookkeeping on rescope so a
reconcile never PATCHes one gateway's pins to another.

Legacy globally-keyed values are deliberately not migrated into remote
scopes: ownership of rows accumulated by every window is unknowable
(the #67709 precedent), and backend-mirrored pins self-heal from the
gateway's own `pinned` rows.

Fixes #77318
2026-08-16 16:27:00 -07:00
addel decd6a73fa fix(desktop): preserve registry route identity 2026-08-16 16:26:50 -07:00
addel c76b7e6343 fix(desktop): harden plugin route lifecycle 2026-08-16 16:26:50 -07:00
addel 17271a8a6b fix(desktop): route plugin profiles through registry 2026-08-16 16:26:50 -07:00
David Dudok de Wit 496946ba8c fix(desktop): report remote plugin target profiles 2026-08-16 16:26:50 -07:00
addel 27e4f09540 feat(desktop): expose connection-aware plugin routing 2026-08-16 16:26:50 -07:00
Brooklyn Nicholson a01d2ee21a fix(desktop): declare the repository so publish resolution can succeed
With a GH_TOKEN/GITHUB_TOKEN in the environment, electron-builder auto-selects
the github provider and resolves owner/repo from the repository field, falling
back to reading <projectDir>/.git/config. projectDir is apps/desktop, which has
no .git of its own, and app-builder-lib does not walk up to the workspace root
-- so resolution returned null and threw "Cannot detect repository by
.git/config".

On Linux this fires from onAfterPack for a plain `dir` target: the darwin and
Windows branches return early for non-installer targets, Linux has no such
guard. That is why the same build worked elsewhere.

--publish never keeps `pack` from reaching this at all, but `dist:*` and
test-desktop.mjs still resolve publish config on a machine with a token, so
declare the field too.

Tests call the real app-builder-lib resolver rather than asserting on the text
of package.json, so they track electron-builder's behavior instead of our
formatting.

Co-authored-by: airo7 <airo7@users.noreply.github.com>
Co-authored-by: frankmendes1979 <frankmendes1979@users.noreply.github.com>
2026-08-16 12:48:29 -07:00
Brooklyn Nicholson 1e82967bcc fix(desktop): keep the local pack out of electron-builder's publish path
`hermes desktop` runs `npm run pack` through _npm_lifecycle_env(), which
sets CI=1. electron-builder 26 reads that as an implicit publish request
(`onTagOrDraft`) when --publish is absent, so a local --dir build enters
publish resolution it has no business being in.

Pin `--publish never` on the pack script. This is also what electron-builder
asks for directly -- the implicit CI behavior is removed in v27.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: fangliquanflq <fangliquanflq@users.noreply.github.com>
2026-08-16 12:48:29 -07:00
hermes-seaeye[bot] ca84f13b97 fmt(js): npm run fix on merge (#87880)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 18:07:16 +00:00
pierrenode 2eb9d29280 fix(desktop): scope pluginSocket's connection to the active profile
pluginSocket (hermes.ts) is documented as "the live twin of pluginRest,
scoped the same way", but it calls window.hermesDesktop.getConnection()
with no profile argument, while pluginRest passes the active profile via
profileScoped(). getConnection's IPC handler (ensureBackend in
electron/main.ts) falls back to the primary profile whenever the profile
argument is empty, so an unscoped call always resolves to the primary
profile's backend regardless of which profile is actually active.

For a plugin used from a non-primary profile (e.g. kanban), this means REST
calls go to the correct pooled backend while the plugin's WebSocket silently
connects to the wrong one — a multi-profile user sees one profile's data
with another profile's live events.

Fix (adapted to the post-#87600 registry-agent store shape during salvage):
resolve the plugin socket's connection through the same (connectionId,
profile) source of truth ensureGatewayProfile/ensureGatewayAgent maintain
for $connection — store/gateway's setActive now pushes the active scope's
registry connection id into the hermes module (setApiRequestConnection,
the no-store-import twin of setApiRequestProfile), and pluginSocket
resolves via getConnectionFor for registry-agent scopes and
getConnection(profile) for the local pool. The plugin socket therefore
follows registry-agent activations too, not just profile switches.

voice-playback.ts's resolveSpeakStreamUrl had the same gap originally, but
main has since fixed it independently (via the getApiRequestProfile()
getter rather than direct store access) — dropped from this PR as
redundant, keeping only the still-open pluginSocket gap.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-08-16 11:00:17 -07:00
fangliquanflq c23605ef76 fix(apps): dial primary sleep/wake reconnect at window backend not active profile 2026-08-16 11:00:01 -07:00
604maestro 0a6ead0e9b fix(desktop): ignore stale remote connection attempts 2026-08-16 10:59:19 -07:00
hermes-seaeye[bot] 410c437955 fmt(js): npm run fix on merge (#87844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 17:26:10 +00:00
hermes-seaeye[bot] b007b80cb7 fmt(js): npm run fix on merge (#87839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 17:16:57 +00:00