Skip to content

feat(desktop): standalone "Hermes Remote" flavor (remote-first client)#1

Merged
AIalliAI merged 1 commit into
base/desktop-remote-flavorfrom
feat/desktop-remote-flavor
Jun 17, 2026
Merged

feat(desktop): standalone "Hermes Remote" flavor (remote-first client)#1
AIalliAI merged 1 commit into
base/desktop-remote-flavorfrom
feat/desktop-remote-flavor

Conversation

@AIalliAI

Copy link
Copy Markdown
Owner

Standalone "Hermes Remote" desktop flavor (remote-first client)

A separate, remote-first build of the desktop app that connects to a remote Hermes gateway instead of bootstrapping a local Python backend — while keeping the local backend as an explicit opt-in. Reuses the existing remote-connection layer (token + OAuth/ws-ticket) almost entirely; the new work is build identity, a connect-first boot path, and a first-run connect screen.

Installs alongside the full app (own appId com.nousresearch.hermes-remote, product "Hermes Remote", hermes-remote:// scheme, separate userData/keychain).

What's here

  • Flavor mechanismelectron/flavor.cjs (pure, unit-tested), resolved in main.cjs from HERMES_DESKTOP_FLAVOR or a bundled build/flavor.json; flavor-derived deep-link protocol + product identity.
  • Packagingelectron-builder.remote.cjs (spreads the base build, overrides identity) + dist:*:remote / build:remote / dev:remote npm scripts; flavor.json in extraResources; parameterized Windows exe identity.
  • Connect-first boot gatestartHermes() surfaces a backend.needs-remote phase instead of bootstrapping; hermes:connection-config:use-local IPC for the local opt-in (persisted as localOptIn).
  • First-run connect screenRemoteConnectOverlay wrapping the existing probe/test/apply/OAuth IPC via a shared use-remote-connect hook (also adopted by gateway-settings to avoid drift); use-gateway-boot bails gracefully on the gate; connecting-overlay suppressed in that phase.

How to build / run

cd apps/desktop
npm run dev:remote        # dev: Electron in remote flavor -> connect screen
npm run dist:mac:remote   # packaged (also dist:win:remote / dist:linux:remote)

Verification

  • typecheck / tsc -b clean; remote-flavor vite build succeeds; lint clean on changed files.
  • New tests: electron/flavor.test.cjs (8/8) and remote-connect-overlay.test.tsx (6/6: token, OAuth, local opt-in, OAuth-stale-state regression).
  • Electron suite: only failure is the pre-existing windows-child-process test (Windows test on macOS, untouched here).
  • Adversarial multi-agent review (3 dimensions): all core correctness claims verified; confirmed minor findings (stale OAuth signed-in on URL edit, flavor-derived app name, a misleading comment) fixed.

Not covered (manual / follow-up)

  • Headless Electron dev-run + signed dist:mac:remote (need a real machine).
  • Dedicated remote icon set; slim build dropping native-deps; thin-client self-update story (currently shares the rebuild-in-place updater).

Add a separate, remote-first build of the desktop app that connects to a
remote Hermes gateway instead of bootstrapping a local Python backend, while
keeping the local backend as an explicit opt-in.

- Build flavor mechanism (electron/flavor.cjs): HERMES_DESKTOP_FLAVOR / bundled
  flavor.json; flavor-derived deep-link protocol and product identity.
- electron-builder.remote.cjs + dist:*:remote scripts: own appId
  (com.nousresearch.hermes-remote), product name, and hermes-remote:// scheme,
  installable alongside the full app.
- Connect-first boot gate in startHermes(): surfaces a backend.needs-remote
  phase rather than bootstrapping; hermes:connection-config:use-local opt-in.
- First-run RemoteConnectOverlay wrapping the existing probe/test/apply/OAuth
  IPC via a shared use-remote-connect hook (also adopted by gateway-settings).
- Tests: electron/flavor.test.cjs, remote-connect-overlay.test.tsx.
@AIalliAI
AIalliAI marked this pull request as ready for review June 17, 2026 06:54
@AIalliAI
AIalliAI merged this pull request into base/desktop-remote-flavor Jun 17, 2026
7 checks passed
@AIalliAI

Copy link
Copy Markdown
Owner Author

Merge notes

Summary. Adds a separate, remote-first desktop build — "Hermes Remote" — that connects to a remote Hermes gateway instead of bootstrapping a local Python backend, with the local backend kept as an explicit opt-in. The existing full desktop app is unaffected.

Compatibility / risk

  • Full flavor (default) is byte-for-byte unchanged. With no HERMES_DESKTOP_FLAVOR and no bundled flavor.json, the flavor resolves to full: same appId/productName, same hermes:// scheme, same first-run local bootstrap. The new boot-gate branch is guarded by remoteFirstGateActive(), which is always false in the full flavor.
  • Additive only — no breaking changes. New IPC (hermes:connection-config:use-local) and the connection.json localOptIn field default to off and round-trip through the existing read/coerce/write paths. No existing IPC, config schema, or boot phase semantics changed.
  • The deep-link scheme is flavor-derived (hermes vs hermes-remote) so the two apps can coexist on one machine; the in-app media protocol stays shared (session-scoped, no collision).

What merges (22 files, +1079 / −131)

  • Flavor core: electron/flavor.cjs (+ flavor.test.cjs), wired into main.cjs (flavor constant, deep-link protocol, flavor-derived app name).
  • Packaging: electron-builder.remote.cjs; build/flavor.json stamp via scripts/write-build-stamp.cjs; parameterized set-exe-identity.cjs / after-pack.cjs; dist:*:remote / build:remote / dev:remote scripts; flavor.json in extraResources.
  • Boot gate: startHermes() surfaces a backend.needs-remote phase instead of bootstrapping; use-local opt-in IPC + preload bridge.
  • Renderer: RemoteConnectOverlay (+ test) over a shared use-remote-connect hook (also adopted by gateway-settings); use-gateway-boot bails on the gate; connecting-overlay suppression; i18n (boot.connect.*, en/zh + types).

Post-merge / ops

  • Build the client with npm run dist:mac:remote (also :win / :linux); npm run dev:remote for local dev.
  • Distributable: appId com.nousresearch.hermes-remote, product "Hermes Remote" — installs alongside the full app with its own userData/keychain.
  • electron/flavor.test.cjs is wired into test:desktop:platforms.

Verification

  • typecheck / tsc -b clean; remote-flavor vite build succeeds; changed files lint clean.
  • Tests: flavor.test.cjs 8/8, remote-connect-overlay.test.tsx 6/6 (token / OAuth / local opt-in / OAuth-stale-state regression).
  • Only failing electron test is the pre-existing windows-child-process case (Windows test on macOS), untouched by this change.

Follow-ups (non-blocking)

  • Dedicated "Hermes Remote" icon set; slim build dropping native-deps; thin-client self-update story (currently shares the rebuild-in-place updater).
  • Pre-existing unused net import in electron/main.cjs (present on upstream main) trips npm run lint — unrelated, left for a separate cleanup.

AIalliAI pushed a commit that referenced this pull request Jun 19, 2026
Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger).
No call-site changes; this phase only makes the abstraction exist + tested
in isolation.

Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC.
  Required surface is name + start; is_available()/stop() carry safe defaults.
  is_available has a no-network invariant. Docstring marks it experimental
  until the Chronos provider (Phase 4) validates the shape.
Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling
  cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses
  stop_event.wait(interval) for responsive stop (both raw tickers already do).

Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick
and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat
guard: required abstractmethods must stay exactly {name, start}, so the three
Phase-4 hooks land as NON-abstract additions).

tick() internals in cron/scheduler.py are byte-unchanged (only new file added).
Phase 0 characterization tests still green. Full tests/cron/: 445 passed.
AIalliAI pushed a commit that referenced this pull request Jun 27, 2026
* fix(windows): harden gateway scheduled task

* fix(windows): launch gateway scheduled task via console-less wscript

The Scheduled Task ran the gateway through cmd.exe, which allocates a
console. During logon Windows broadcasts CTRL_CLOSE_EVENT to console
process groups, reaping cmd.exe and the half-initialized gateway with
STATUS_CONTROL_C_EXIT (0xC000013A) - which Task Scheduler treats as a
user cancel, so RestartOnFailure never fires and the gateway vanishes on
every reboot (issue NousResearch#45599 root cause #1).

Add a console-less .vbs launcher (wscript.exe -> pythonw.exe, both
GUI-subsystem) mirroring the gateway.cmd env + argv, and point the task
action at it. The .cmd stays for the Startup-folder fallback and /Run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jeff <jeffrobodie@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AIalliAI pushed a commit that referenced this pull request Jun 27, 2026
…eation snapshot (NousResearch#44585)

An unpinned cron job follows the global default provider (config.yaml
model.default + resolve_runtime_provider). If that global state is changed
after the job is created — e.g. a temporary switch to a paid provider like
nous/claude-fable-5 — the job silently inherits it on its next tick and spends
real money. This is the reported $7.73 incident: a job created under a
free/default provider later inherited a temporary paid switch.

Fix (ask #1 only) preserves the legitimate "unpinned job should follow
model.default" use case by detecting *drift* rather than freezing the model:

- create_job (cron/jobs.py): for UNPINNED, agent-backed jobs (no explicit
  provider, not no_agent), snapshot the provider that resolution WOULD pick
  right now into a new optional `provider_snapshot` field, resolved via the
  same resolve_runtime_provider() path the ticker uses. Fail-open to None on
  any resolution error so job creation never breaks.

- run_job (cron/scheduler.py): right after runtime resolution, if the job has
  a provider_snapshot AND is unpinned AND the currently-resolved provider
  DIFFERS from the snapshot, fail closed for that run — make no paid call and
  deliver a loud, actionable alert naming both providers and telling the user
  to pin explicitly (`cronjob action=update job_id=.. provider=..`).

Back-compat: jobs with no snapshot (pre-existing jobs, no_agent jobs, or any
job whose creation-time resolution failed) behave exactly as before — the
guard only engages when a snapshot exists. Explicitly-pinned jobs (job.provider
set) are unaffected since they don't drift with global state.

Tests: tests/cron/test_cron_provider_pin.py covers snapshot-matches (runs),
snapshot-differs (fail closed, no agent constructed), no-snapshot back-compat,
None-snapshot back-compat, explicitly-pinned (runs regardless), plus create_job
snapshot capture/skip/fail-open. The fail-closed case is load-bearing (fails
without the guard).

Issue NousResearch#44585 asks #2-4 (hard-stop a running job, gateway-stop containment,
fail-closed on provider mutation) are out of scope for this change.
AIalliAI pushed a commit that referenced this pull request Jul 4, 2026
…ture

get_copilot_api_token now returns (api_token, base_url); the auth-remove
suppression test still mocked it as a bare string, mis-unpacking into the
credential-pool seed path and failing with 'No credential #1'.
AIalliAI pushed a commit that referenced this pull request Jul 4, 2026
…_id signature churn

Two independent bugs evicted the cached gateway AIAgent on every turn,
preventing the prompt cache from ever warming:

1. Model normalization mismatch: the post-run fallback-eviction check
   compared _agent.model (stripped in AIAgent.__init__) against the raw
   _resolve_gateway_model() config string. For vendor-prefixed config on
   native providers (e.g. 'deepseek/deepseek-v4-pro' vs 'deepseek-v4-pro')
   this was always unequal, so the agent was evicted after every
   successful run. Normalize _cfg_model the same way (skip aggregators).

2. Discord triggering message_id leaked into the cached system prompt via
   build_session_context_prompt()'s Discord IDs block. message_id changes
   every turn, so the agent-cache signature (computed from the ephemeral
   prompt) changed every Discord turn -> rebuild every message. The id is
   now injected per-turn into the user message (where per-turn content
   belongs and does not touch the cache signature); the cached IDs block
   carries a static pointer to it, preserving reply/react/pin via the
   discord tools.

Adapted from NousResearch#28846. Bug #1 fix is the contributor's; bug #2 reworked to
be non-destructive (keeps the triggering-id capability instead of deleting
it). Redundant auto-reset eviction (already on main via NousResearch#9893/NousResearch#48031) and
the wrong-premise reset_context_note plumbing from the original PR were
dropped.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
AIalliAI pushed a commit that referenced this pull request Jul 4, 2026
… fail on '(empty)' sentinel

Two related bugs caused subagent delegation to silently return empty summaries
with 0 tokens when the user configured delegation.provider=bedrock alongside
delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com.

Root cause #1 — misrouting in _resolve_delegation_credentials():
  The configured_base_url branch unconditionally forced provider='custom' and
  api_mode='chat_completions', only specializing for chatgpt.com, anthropic,
  and kimi hosts. Bedrock (and other native-SDK providers) fell through as
  'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at
  Bedrock's native API. Bedrock rejected the payload and returned nothing,
  which looked like an empty LLM response to the child agent.

  Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip
  the base_url short-circuit and fall through to resolve_runtime_provider(),
  which knows how to construct the proper SDK client. base_url can still be
  forwarded through that path for regional overrides.

Root cause #2 — '(empty)' sentinel accepted as success:
  After N retries of empty LLM responses, run_agent.py emits the literal
  string '(empty)' as final_response. _run_single_child then hit
  `elif summary:` — '(empty)' is truthy, so status became 'completed' and
  the parent surfaced a blank result with no error. Users saw api_calls=4,
  tokens=0, duration~0.4s, status=completed.

  Fix: treat final_response.strip() == '(empty)' as a failure so the parent
  surfaces it instead of silently accepting zero-content 'success'.

Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock
(provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by
new tests in tests/tools/test_delegate.py.
AIalliAI pushed a commit that referenced this pull request Jul 26, 2026
…onnect ladder can't freeze silently (NousResearch#66377)

The Telegram gateway could go silently deaf for hours: the reconnect ladder
stalled mid-way (e.g. "attempt 4/10, reconnecting in 40s" then nothing) while
the process stayed active(running), so Restart=always never fired.

Root class: every recovery path — the ladder's re-entry
(_schedule_polling_recovery), the pending-update probe (_probe_pending_updates),
and PTB's error callback — gates new recovery on _polling_error_task.done(). If
that single task wedges on any hung await, all recovery returns early forever
and nothing retries.

The heartbeat loop is a separate task, so make it an independent, cause-agnostic
watchdog: if the same recovery task stays in-flight past
_POLLING_ERROR_TASK_STUCK_TIMEOUT (300s — well beyond a healthy ladder attempt's
bounded stop+drain+start+backoff), force a retryable-fatal so the background
reconnector rebuilds the adapter instead of relying on the frozen ladder. This
guarantees progress regardless of *where* the stall is (issue direction #1),
tracked locally so no task-assignment site needs to change.

Also salvages @koduri-mahesh-bhushan-chowdary's NousResearch#66492 (drain-await timeout),
which closes the one concrete wedge vector documented in the incident
(_drain_polling_connections' unbounded shutdown()/initialize() on a wedged
CLOSE-WAIT pool). The watchdog covers the rest of the class.

Co-authored-by: Koduri Mahesh Bhushan Chowdary <mkoduri73@gmail.com>
AIalliAI pushed a commit that referenced this pull request Jul 26, 2026
…reaming

Two real render-cost wins found by inspection (no behavior change):

1. Sidebar re-rendered on every stream token. $sessionStates is republished on
   every message delta (tens/sec during a turn), and the derived ID computeds
   ($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds)
   allocated a fresh array each time. nanostores notifies on !==, so the whole
   ChatSidebar + every mounted row re-rendered per token even when the working/
   attention/background set was unchanged. Return the previous array reference
   when the contents match → nanostores skips the notify unless the set actually
   changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar.

2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant`
   (lowercase + whitespace-collapse over the entire read_file/terminal payload)
   ran twice in the ToolEntry render body, so every completed tool re-normalized
   its whole output on every stream tick of the running message. Memoize on the
   view fields so it recomputes only when the tool's content changes.

Both are correctness-preserving (stable refs + memoization). The CI stream
scenario drives $messages directly, not the publishSessionState path, so it
won't reflect #1 — verified by inspection.
AIalliAI pushed a commit that referenced this pull request Jul 26, 2026
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression:
the top `if (reduce) setPhase('gone')` fired unconditionally on mount
whenever reduce-motion was on, so every OS reduced-motion user lost the
CONNECTING overlay during cold boot entirely (jumped to 'gone' before the
gateway was even open). The intent was to skip the exit *choreography*,
not to skip showing the overlay. Removed the unconditional top block and
the redundant nested preview block; kept only the third branch
(`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' :
'text-out'`) which correctly gates the short-circuit on connect. Also
fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line
comment pasted three times.

Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI.
Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts,
adds @playwright/test types) and wired it into the typecheck script. This
surfaced three latent type errors that are fixed in the same commit:
  - fix-electron-tracing.ts: `app._context` and `electron._playwright` are
    private APIs — added `as any` on the access before the existing cast.
  - playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:`
    is not a valid UseOptions property in playwright 1.58; it's a
    BrowserContextOption accessed via `contextOptions: { reducedMotion:
    'reduce' }`. The old form was silently ignored at runtime, so
    reduced-motion emulation wasn't actually active — screenshots could
    catch overlays mid-fade (exactly what the comment warned about).

Nit #2 — fix-electron-tracing.ts reaches into Playwright internals
(_playwright, _allContexts, _context) with no public contract. Added a
header comment calling out the `@playwright/test` exact pin (=1.58.2) so a
future bump knows to re-verify the private symbols still exist.

Nit #3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation.

Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors;
vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass;
npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
AIalliAI pushed a commit that referenced this pull request Jul 26, 2026
…native extension)

unicode61 indexes a CJK run as ONE token, so 2-char Korean terms (일본,
구글, 우리, ...) can never match it and the trigram tokenizer needs >=3
chars per term — any query containing a 1-2 char CJK token falls through
to a LIKE full-table scan (measured 3-6.4s CPU per query on a 6.8GB
production state.db; the #1 base cost behind a 12.4s session_search
average on CJK workloads).

This ships a ~250-line loadable FTS5 tokenizer (no deps) that wraps
unicode61: maximal CJK runs inside its tokens are re-emitted as
overlapping character bigrams (Lucene CJKAnalyzer semantics), everything
else passes through unchanged. FTS5 phrase semantics turn consecutive
sub-tokens into exact substring matching down to 2-char terms at index
speed.

Build: native/fts5_cjk/build.sh -> ~/.hermes/lib/libfts5_cjk.so
(override: HERMES_FTS5_CJK_SO).

Salvaged from PR NousResearch#65544; the schema integration lands separately on the
v23 external-content layout.
AIalliAI pushed a commit that referenced this pull request Jul 26, 2026
…add same-pid self-reclaim guard

Hardening on top of the salvaged dead-PID lease reclamation from PR NousResearch#65775
(@the3asic):

- Probe via psutil.pid_exists (hard dependency; CONTRIBUTING.md critical
  rule #1) with the contributor's os.kill(pid, 0) POSIX probe retained
  only as a scaffold-phase fallback when psutil is missing.
- Same-process holders (pid == os.getpid()) are never probed and never
  self-reclaimed — another thread's live lease is owned by the lease
  refresher/release path.
- Any probe doubt (exceptions, permission errors) conservatively keeps
  the lease until normal TTL expiry; Windows stays TTL-only.
- Tests: psutil-first dead-pid reclaim (probe call pinned), os.kill
  fallback path, probe-doubt keeps lease, same-pid no self-reclaim,
  legacy holder + Windows paths assert NO probe via either API.
AIalliAI pushed a commit that referenced this pull request Jul 26, 2026
…ch#67140)

The background write guard decided ownership from `isinstance(usage_rec, dict)`,
so a local skill with NO usage record passed. That successful write called
bump_patch(), which created a `created_by: null` record — and the identical
write was refused from then on. "Allowed exactly once, then never" is a race
with our own bookkeeping, not a policy. Reproduced on main: patch #1 succeeds,
patch #2 with the same arguments is refused.

Option B from the issue. Option A (split `session_review` from
`scheduled_curator` and let the session fork patch user-owned skills it
consulted) would widen autonomous write permission onto skills the user owns
with no user present to consent — wrong direction for a no-user-present actor.

- skill_manager_tool: missing and explicit-null records now resolve
  IDENTICALLY, both fail closed. The refusal names the reason and points at
  `hermes curator adopt <name>`.
- background_review: both review prompts told the reviewer to patch any skill
  consulted in the session and claimed pinned skills could be improved, while
  enforcement refused both. Prompts now list pinned, external, and user-owned
  skills as protected, and tell the reviewer to RECOMMEND adoption instead of
  attempting a write that will be refused.
- skill_usage: document that `created_by` is a curator-management policy flag,
  not a provenance claim, and add `is_curator_managed()` so call sites read as
  the question they ask. Field name retained — it is on disk in every
  `.usage.json` and renaming would strand those records.
- curator CLI: `hermes curator list-unmanaged` itemizes unmanaged skills with
  the reason each is unmanaged (completes the NousResearch#67139 spec).

Foreground writes are untouched: a user-directed edit to a user-owned skill
still works, including on pinned skills.

Sibling tests: 9 failures in test_skill_manager_tool.py were fixtures that
created record-less skills to exercise OTHER guards (consolidation-delete,
read-before-write) and relied on ownership falling through. Fixed at the
fixture, since the real curator only ever operates on managed sediment. One
test asserted the old "manually authored" wording; rewritten to assert the
behavior contract instead of the string.

Validation: 274 targeted tests + all 7 background-review files (60 tests) pass.
E2E on a temp HERMES_HOME (30 checks) covers the flip, foreground writes,
adoption unblocking, pin semantics, prompt/enforcement parity, and the new verb.
Each new test sabotage-verified: revert the fix, confirm it goes red.

Fixes NousResearch#67140
AIalliAI added a commit that referenced this pull request Jul 26, 2026
…l_dirs, indexed lookup

Three substantive changes to the kanban create-time skill validator:

1. Mixed valid/invalid skills (review #1): Reject only when ALL
   requested skills are unresolvable. When some load and some don't,
   accept the task — matching main's behavior (commit 018009b) of
   skipping unknown entries and continuing with whatever loaded.

2. Relative external_dirs (review #2): Resolve relative paths in
   skills.external_dirs against the worker's HERMES_HOME, not the
   creator cwd. Mirrors get_external_skills_dirs() in agent/skill_utils.py.

3. Shared indexed skill lookup (review #3): Replace raw rglob() with
   iter_skill_index_files() so excluded paths (VCS, venv, node_modules,
   cache dirs, skill support dirs) are not counted. Add ambiguity
   rejection: when a name matches across multiple search roots, treat
   it as unresolvable — matching skill_view()'s refusal to guess.

 +6 new tests covering mixed valid/invalid, relative external_dirs,
   ambiguity rejection, excluded paths, and skill support dir exclusion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant