Commit Graph

7178 Commits

Author SHA1 Message Date
Brooklyn Nicholson
c69eda0d56 chore(desktop): regenerate the i18n key manifest; type the canonical-chat mocks without import() 2026-09-30 11:05:17 -05:00
Brooklyn Nicholson
aecf596db9 refactor(bots): share the stale Bot Chat tile probe between the roster click and tip open
isStaleBotChatTile(canonicalIds) replaces the copy in openStoredBotChat, whose
try/catch duplicated the optional call; the stale-tiles test no longer
contradicts its own comment.
2026-09-30 10:31:18 -05:00
Brooklyn Nicholson
fe5b728bd9 fix(bots): size live_message_count with SessionDB's own display projection
The count left out DISPLAY_VISIBLE_SQL, so model-only rows from
micro-compaction were counted while the reader painted none, and the history
wait this count exists to skip still ran. SessionDB.display_message_count now
counts the same set _display_rows_from_conn paints, through _read_one instead
of reaching into db._lock/_conn, and one _live_count_field serves all three
listing paths. The field is declared on SessionListRow, ProfileSessionPreview
and ProfileCanonicalSession (extra=forbid) and the desktop derives it from the
generated contract instead of hand-typing it twice.
2026-09-30 10:12:37 -05:00
Brooklyn Nicholson
3ace90ae4b refactor(bots): type the compression-tip tile probe off the SDK host
The stale-tile probe added before the tip open reached the host hook through a
cast and split the forceResume comment. Call the typed optional hook directly.
2026-09-30 09:57:26 -05:00
finn763
f8bf71b06e fix(desktop): discard stale Bot Chat tile before opening compression tip
Every Bot Chat compression left two tabs captioned with the bot name: the
lineage guard from 202997b51d matches through sidebar-attached lineage
metadata, which a hidden Bot Chat lineage never carries. Run the same
owner-scoped stale-Bot Chat probe the roster click runs inside
openStoredBotChat, against the registry id + resolved tip, before
host.openSession. Covers activity-refresh and reclaim opens. Side tabs
with other titles are preserved.

Closes #120810

(cherry picked from commit 60fc979349ae90aefba3465efc13c961da37b556)
2026-09-30 09:57:07 -05:00
kokhlo
80c0d1a2f4 fix(bots): key the Bot Chat history wait on paintable rows
A bot whose canonical Bot Chat session has message_count > 0 but zero
paintable rows can never be opened from the roster: the click demands a
painted transcript (expectHistory), no read mode serves active = 0,
compacted = 0 rows, so the open burns the full 60s hydration budget and
fails closed, and the fail-closed registry guard then refuses to mint a
replacement either.

Report the paintable size next to the denormalized total:

- session.list title lookups and profiles.list canonical/last_session rows
  now carry live_message_count — the count of display-set rows
  (active = 1 OR compacted = 1) a stored-transcript read actually paints,
  distinct display groups counted once.
- the desktop keys expectHistory off live_message_count when present,
  falling back to message_count for older gateways, so the count and the
  paint gate can no longer disagree. An all-folded chat opens as
  expected-empty instead of wedging for the whole budget.

(cherry picked from commit ddee47676f5f0669f7cb84681dc2f6c4a72ee4c6)
2026-09-30 09:57:04 -05:00
liuhao1024
99aa3ca0af fix(desktop): front main when a Bot roster click targets the chat it already holds
A Bot Mode roster click whose owner lost its tile (closing the main tab
promotes a neighbouring tile into the workspace pane, dropping its tile)
resolved to a dead click while another bot's tile held the zone: the chat
already lives in main, focusOpenSession won't claim the 'main' hit for a
Bot scope (a Bot tab for the same stored id must stay mintable), and the
in-place navigate is a no-op because the route already points at the
session — nothing ever fronted the workspace pane.

Extract the main-front into frontMainIfSelected and call it from the
in-place tail of openSession when focus landed nowhere. The Sessions
scope keeps its exact 'main' semantics; Bot tab minting is untouched.

Fixes #125899

(cherry picked from commit 44c0466629a15175499a6180013c23554ff76927)
2026-09-30 09:57:02 -05:00
KoNit-K
9cbc6a5ac0 fix(desktop): skip non-primary local bot relay route 2026-09-30 09:15:47 -05:00
chelsealong
ddd0cc6944 fix(desktop): skip transcript reads for a session with no row yet
session.create is intentionally lazy: a brand-new session's state.db row
is only written on the first prompt, so a "New chat" tile opened but
never typed into has a stored id with nothing behind it yet. The
transcript-timeline mount fetch and the periodic active/tile transcript
sync didn't know that and read it anyway, 404ing repeatedly against a
row that cannot exist ("Session not found" x4 in desktop.log before the
first send, #123622).

Track the already-existing "created this run" bookkeeping (used to keep
a flapping-backend resume from being misread as a deleted session) in
its own small module, and skip the transcript/timeline read when a
session created this run still has zero local messages — at that point
there is nothing to fetch, and the empty local view is already correct.
2026-09-29 23:25:40 -05:00
Siddharth Balyan
bddd22be7c fix(desktop): the onboarding tour no longer leaves a preview row for the app's own page (#128211)
* fix(desktop): desktop_ui tools that look at a page no longer record it as a preview artifact

gui_tour targets, drive_preview, annotate_preview and desktop_preview read/close
all carry the page they look at in a top-level url (or url arg). toolPreviewTarget
took that as a deliverable, so after a tour the composer status stack showed the
app's own page (index.html in a packaged build) as a preview row. Only
desktop_preview open still offers its url.

Refs NS-1029 B4

* fix(desktop): a wrapped look at a page no longer records it as a preview artifact

A bridged tool_call reaches the preview check under a name that is not the
real tool. A labelled call renders one row per inner call through
ConnectorExecution, and that row's toolName is the label title ('Gui Tour').
An unlabelled stored part keeps toolName 'tool_call' with the real tool in
args.name or in a one-entry args.calls. Either way the non-deliverable guard
missed a wrapped gui_tour, and its row recorded the app's own page when it
mounted.

ConnectorExecution now passes each row's real tool name as innerToolName (the
label's name for a tool_call entry, else the part's own tool), and
buildToolView judges the preview by it. toolPreviewTarget unwraps a one-call
tool_call to the inner tool and its arguments.

Refs NS-1029 B4
2026-09-30 08:32:11 +05:30
Austin Pickett
dfcbef358a fix(desktop): find a source install through its published user-bin launcher
The PM rework dropped the resolver's "existing hermes on PATH" rung, so
Desktop only accepts HERMES_DESKTOP_HERMES or $HERMES_HOME/hermes-agent. A
healthy install elsewhere (install.sh --dir, a setup-hermes.sh clone) now
falls to bootstrap-needed and the first-run choice, offering to install a
second copy. The old rung also read raw process.env.PATH, which a
Finder/Dock launch inherits without ~/.local/bin.

Add a rung before bootstrap-needed: probe the launcher published at its
fixed user-bin location (~/.local/bin or $HERMES_HOME/bin; Windows:
%LOCALAPPDATA%\hermes\bin), read the install directory its --version
reports, and resolve that root through resolveSourceInstallationBackend,
the same validated path ACTIVE_HERMES_ROOT uses. HERMES_DESKTOP_IGNORE_EXISTING
and repair requests still skip it, and a miss is now logged instead of
silent. The launcher lookup is shared with resolveInstallationLauncher.

Fixes #73014

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
2026-09-29 22:57:06 -04:00
Austin Pickett
a35f600b8a fix(desktop): resolve the Windows ssh client instead of hard-coding System32 OpenSSH
All ssh spawns (the -G config probe, the ssh-config:resolve IPC, SSH
terminals, and SshConnection) now go through one pure resolver:
desktop.ssh_path in config.yaml, then System32 OpenSSH, then Git for
Windows' usr\bin\ssh.exe, then bare ssh. Non-Windows keeps bare ssh.

A failed local ssh -G probe is now tagged terminal: boot latches on the
failure overlay (naming the client and the ssh_path setting) instead of
re-driving the same probe every ~2s.

Fixes #103288
2026-09-29 22:56:15 -04:00
ethernet
046f709b6d Merge pull request #128749 from Navlem/fix/desktop-first-send-abort
fix(desktop): keep the first send when only the active runtime is retargeted
2026-09-29 22:49:59 -04:00
ethernet
c5947dbc66 Merge pull request #128667 from thefiredev-cloud/fix/desktop-fresh-draft-first-send
fix(desktop): send the first prompt of a fresh-draft chat
2026-09-29 22:49:03 -04:00
Brooklyn Nicholson
0466a04bc8 fix(desktop): pipe Windows spawn lock to stdin
The windows_ssh_runtime `write-lock` op takes the ownership ID as its one
argv element and reads the lock JSON on stdin, but the atomic Windows
spawn command appended $lock as a second positional argument, so the
runtime's arity check rejected the spawn with `invalid operation` and a
Windows SSH backend could never claim ownership of its process
(#119111).

Pipe $lock into the helper's stdin instead, keeping the ownership ID
argument, non-zero-exit propagation and $spawnLines|Write-Output intact.

Fixes #119111

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-29 21:45:14 -05:00
Konstantin Khlopkov
f429fef30c fix(desktop): settle managed SSH updates before the local updater handoff 2026-09-29 21:44:34 -05:00
Brooklyn Nicholson
60e2e33e9b fix(desktop): re-arm the terminal mount when the xterm host connects late
The font wait resolves null when host.isConnected is false at an await
boundary; the mount handler returned silently, so a pane rendered before
its host connected stayed blank forever — no term.open, no PTY spawn, no
log line, no retry (#118004). Poll frames until the host connects, then
retry the wait+mount once; cancel the watch on teardown. Same fix for the
agent mirror terminal, which shares the single-shot mount shape.
2026-09-29 21:44:18 -05:00
LordMelkor
1c659fe53a fix(desktop): cleared SSH fields reach main as '' and drift heal aligns route identity
Two bugs that together made an SSH-primary Desktop show "This device" and
spawn a phantom local backend even though it was connected to the remote.

1. The Gateways form serialised a cleared Identity file (and User) as
   `undefined`. Main merges the SSH settings with `??`, where `undefined`
   means "inherit the saved value" and only '' clears, so a stale keyPath
   could never be removed: the form refilled it on every Save and reconnect.

2. reconcileRegistryDrift treated a v1 SSH route as "already registered" on
   host/user/port alone, while matchingConnectionId tags the live window by
   the full route identity (keyPath, remoteHermesPath, remoteProfile too).
   A v1 keyPath the registered entry never carried therefore resolved to no
   connectionId; the renderer treats an untagged window as the unscoped
   local backend, labels it "This device", and the roster force-spawns a
   local child the user never asked for. The heal now aligns the registered
   entry's identity fields with the route the app actually dials.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
2026-09-29 21:43:29 -05:00
Brooklyn Nicholson
830287c745 style: blank line before the retirer override call in the pool-retire test 2026-09-29 21:43:12 -05:00
Brooklyn Nicholson
654300240a fix(desktop): reap keepalive-pinned pool backends idle past a streamed-activity TTL
The renderer's 60s keepalive (touchPoolBackend) refreshes lastActiveAt
for every OPEN chat — the only clock both the idle reaper and LRU
eviction read — so the pinned tier (backends whose profile chat was ever
opened) was exempt from both the maxBackends cap and the idle reaper
forever: N profile chats held N ~120 MB serve children until app quit
(126 processes / 7.5 GB, all parented to the live Hermes.exe) (#105239).

A keepalive proves the chat is open, not that anything streamed.

- touchPoolBackend stamps entry.lastStreamedAt on activeTurn=true
  touches (a prompt turn leasing the backend IS streamed activity).
- pool-retire: PoolRetireEntry gains lastStreamedAt; retireIdle accepts
  an idle-eligibility override so the reaper can key on the streamed
  clock. Every other safeguard is unchanged: activeTurn veto, identity
  checks, admission permit, backend proof probe.
- startPoolIdleReaper retires a local child whose last streamed turn is
  older than POOL_PINNED_IDLE_MS (default 60 min,
  HERMES_DESKTOP_POOL_PINNED_IDLE_MS override) even while keepalive-fresh;
  entries without the stamp keep the legacy lastActiveAt clock. Killing
  is never user-observable: re-focusing the chat re-ensures idempotently
  via ensureBackend/ensureRegistryBackend entry reuse (#105239 req 3).

Note: #118246 already routes local profiles through one host backend;
this TTL bounds what still pools per-profile (isolated mode, unscopable
mutating REST requests, remote profiles' local descriptors aside).
2026-09-29 21:43:12 -05:00
Brooklyn Nicholson
b1027c635f test(desktop): approval.cancelled clears the parked prompt cards by request id 2026-09-29 21:42:20 -05:00
Brooklyn Nicholson
591e3a7ce1 fix(tui_gateway): broadcast approval.cancelled when reap/teardown drop pending prompts
The WS-orphan reap interrupts the turn (deny-resolving the pending approval
queue) and teardown pops it via unregister_gateway_notify — both silently.
A reconnecting client's parked prompt card then answers resolved: 0 (or a
bare 4001 on approval.pending), so the prompt looked lost rather than
cancelled and the user had no signal the command would never run (#106678).

- session_lifecycle: _announce_cancelled_gateway_approvals broadcasts the
  dropped entries (session ids, reason, request ids/count) before the
  deny-resolve drains the queue, from both _interrupt_session_turn and
  _teardown_session. Fail-open: a missing key, empty queue, or broadcast
  failure never blocks the deny-resolve or teardown.
- _interrupt_session_turn gains orphan= so the reaper labels its cancels
  ws_orphan_reap explicitly (never request_id prefix sniffing).
- contracts: ApprovalCancelledPayload event model + regenerated TS/OpenRPC.
- desktop: gateway-event lifecycle consumes approval.cancelled and clears
  the parked prompt cards by request id (mismatch is a no-op, so a newer
  prompt survives); the turn's BLOCKED tool result is the in-transcript
  signal, same as the timeout path.

Supersedes #106693 (KoNit-K) and #126455 (Finn763); design credit to both.

Co-authored-by: KoNit-K <konit.block@protonmail.com>

Fixes #106678
2026-09-29 21:42:20 -05:00
Brooklyn Nicholson
5d4d8aa6d3 fix(desktop): scope the remembered local workspace cwd per profile
Fixes #96834

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-29 21:41:35 -05:00
Brooklyn Nicholson
82bbc694b5 fix(desktop,gateway): proxy client-unreachable remote images through the gateway
An inline agent-generated image (FAL CDN URL) that the Desktop client
cannot reach renders as a collapsed frame with no usable fallback
(#74564): resolveMediaDisplaySrc passes inline https sources through
untouched, and a failed <img> load only records the broken-key.

- Gateway: GET /api/media/proxy fetches an allowlisted remote image URL
  on the client's behalf (the gateway generated the image and holds a
  working route to the CDN) and returns the same data_url shape as
  /api/media. Host-allowlist (fal.media/fal.run/storage.googleapis.com
  and subdomains), 20s timeout, image content-type check, 25MB cap.
- Renderer: gatewayImageProxyDataUrl wraps the new endpoint with
  owner-connection/profile pinning; useMediaImage retries a failed
  inline https load ONCE through the proxy and swaps in the returned
  data URL. The retry is latched per source: a failing proxied data URL
  or a remount never loops, and non-https sources never reach it.
2026-09-29 21:39:53 -05:00
ZenSchema
b592c82892 fix(desktop): add opacity-0 to hidden keep-alive pane layers
Hidden keep-alive pane layers stay mounted with visibility:hidden and
pointer-events:none so scroll position and state survive tab round-trips.
On some compositors these hidden layers can still paint for one frame during
a tab switch, causing accent-colored elements from the outgoing pane to flash
over the incoming pane (e.g. Notes ↔ Tokens).

Add opacity-0 so the compositor drops the hidden layer entirely while keeping
the DOM mounted. This fixes the flash without losing scroll/state preservation.

Closes #101476
2026-09-29 21:30:12 -05:00
Brooklyn Nicholson
8d30c4eaab fix(desktop): stop focusWindow() and ambient reveals from stealing the Windows foreground during streaming (#83998)
On Windows, BrowserWindow.show() and focus() both seize the OS foreground.
focusWindow() did show() + unconditional focus(), and the
sendOpenUpdatesRequested / deep-link handlers did the same — so any ambient
IPC reaching those paths mid-stream dismissed other apps' native save and
confirm dialogs (the reported Notepad repro).

focusWindow() now reveals with showInactive() and guards focus() with
isFocused() so a redundant call never pumps SetForegroundWindow; the
updates-toast and deep-link call sites get the same guarded pattern. The
policy lives in electron/window-focus-policy.ts (pure, unit-tested).
User-intentional raises (pet overlay toggle/open clicks, HUD/Quick Entry
reveals) keep show()+focus() — a click is consent.

Supersedes #84026 (frizikk), which guarded only the renderer side and
explicitly left the main-process focus paths unchanged.

Fixes #83998

Co-authored-by: Jakub Wolniewicz <4850809+frizikk@users.noreply.github.com>
2026-09-29 21:10:10 -05:00
chelsealong
d5c20c3ebd fix(desktop): don't suppress the attach-monitor's own recovery claim
startAttachedBackendMonitor's dead/drifted-backend recovery called
invalidatePrimaryConnection(), which sets primaryRecoverySuppressed
before scheduleUnexpectedPrimaryRecovery() reads it back as an
intentional teardown and refuses to claim the respawn, leaving the
desktop app with no backend. Clear the slot via
backendConnectionState.invalidate() directly instead, since an
unexpected attach teardown is exactly what the supervisor's recovery
path exists for.
2026-09-29 20:58:03 -05:00
chelsealong
0029d25f47 fix(desktop): detect attached-backend token drift, not just liveness
startAttachedBackendMonitor's 15s poll only checked that the attached
backend answers its readiness probe. /api/health and /api/status are
public routes, so a backend recycled by an external supervisor
(launchd KeepAlive) into a new process serving a brand-new session
token still answers 200 — the probe passes while every authenticated
call 401s forever, since child-exit recovery never fires for a
backend this app did not spawn.

Re-read the served token (already exposed via resolveServedDashboardToken)
on every tick and treat drift the same as "gone", routing into the
existing recovery path that re-adopts the token on respawn.

Fixes #121988
2026-09-29 20:58:03 -05:00
Brooklyn Nicholson
8b4b440ef3 fix(desktop,pm): stop the boot parking on a failed update receipt and bound completion retries (#122206)
Three gaps stranded the app through a legitimately long or failed
source-update completion:

- update-gate: a fourth gate signal 'failed-receipt' (hasFailedReceipt on
  UpdateGateDeps, optional) reports a FINISHED, failed update even when a
  stale marker survives; waitForUpdateClearance gains abandonOn so the
  boot path stops parking the full 20-minute budget on a receipt that
  already says failed and boots the current build instead.
- backend-ready: while the backend prints venv_sync's source-completion
  banners the 90s port-announce deadline is re-armed (5-min grace,
  30-min total cap, real wall clock) so a healthy multi-minute repair is
  not killed and re-run on every boot.
- venv_sync: relaunch-driven completion-tail retries are bounded — an
  attempts record beside the pending marker keeps the first retry
  immediate (the historical flaky-tail case), backs off after 2
  consecutive failures (15-min window), and caps at 6 attempts, past
  which the launch keeps the marker, skips the tail, and points at
  'hermes update'; success clears both the marker and the record.
2026-09-29 20:57:51 -05:00
Finn763
78b5b7abb8 fix(desktop): cold-start-tolerant timeouts for remote backend connections
Busy remote hosts routinely take 60-150s to cold-boot a pooled
`hermes serve --isolated` before it prints HERMES_BACKEND_READY, but the
desktop gave up at the hardcoded 45s budget and surfaced a timeout toast
milliseconds before the healthy backend announced ready (issue #94642).

- remote-lifecycle.ts: raise DEFAULT_READY_TIMEOUT_MS 45s -> 120s and add
  resolveReadyTimeoutMs(env) honoring HERMES_DESKTOP_REMOTE_READY_TIMEOUT_MS,
  clamped to a 45s floor (mirrors the local analog in backend-ready.ts).
  Wired into scrapeReadyPort and connect.
- windows-remote-lifecycle.ts: the Windows remote connect sibling used the
  same hardcoded 45s budget; it now resolves through the same helper.

Regression tests cover the env override, below-floor clamp, fractional
rounding, and malformed / non-positive fallback of the resolver.
2026-09-29 20:57:31 -05:00
mfwagner
1b62c4e442 fix(desktop): drop dead pooled remote backends with a cadence-sized failure window
Pooled remote backends are probed from the renderer's reconnect IPC on a
minutes-long cadence once the primary connection is healthy. The shared
liveness tracker's 60s failure window — calibrated for the primary's
sub-minute retry loop — expired between pooled probes, so a dead pooled
descriptor's failure streak reset on every tick, the drop path was never
reached, and the pool served the dead descriptor (ECONNRESET on every
routed call) until app restart.

Give pooled revalidation its own tracker with a 15-minute failure window:
wide enough for the observed ~4-minute probe cadence to accumulate the
3-failure streak, narrow enough that genuinely stale failures still reset.
recordSuccess() clears streaks on recovery, so the wider window cannot
produce false drops.

Closes #94381
2026-09-29 20:57:31 -05:00
Brooklyn Nicholson
0590ab25fe fix(marker): zombie-aware stale-marker self-heal parity across all readers
Rust live_marker_owner now REMOVES a stale marker (dead pid, past ceiling,
unparseable) on read — previously stale bytes were only ignored, so a crashed
updater whose Drop never ran refused every later acquire until the 20-minute
ceiling (#77259). pid_is_alive gains pid-0 and zombie handling (Linux /proc
state, macOS ps stat), closing the kill(pid,0) false-positive that kept a
dead-but-unreaped updater 'live'.

Python _pid_is_running gains the same zombie awareness via a new
_process_state probe (Linux /proc, macOS ps), which read_live_update inherits
through update_lock._pid_alive — so the CLI gate self-heals a zombie-owned
marker in seconds instead of 20 minutes (#120635, #125932).

Electron readLiveUpdateMarker consults a new posixProcessState probe
(injectable, fail-open to the signal-0 verdict) so the desktop boot gate no
longer parks on a zombie-owned marker.

Rust fix cherry-picked from #77885 (authorship kept); Python and Electron
are new parity work.
2026-09-29 20:57:17 -05:00
webtecnica
0ca79e3602 fix(installer): self-heal stale update marker after crash (#77259) 2026-09-29 20:57:17 -05:00
chelsealong
3bc6a022c9 fix(desktop): report the real POSIX drain refusal instead of a generic error
The managed-update termination probe (buildOwnedTerminationCommand) signals
REFUSED, DARWIN_UNAVAILABLE, UNAVAILABLE, and TIMEOUT via a non-zero process
exit code, but SshConnection.exec() throws on any non-zero remote exit before
the caller can inspect stdout. terminateOwnedDashboardForUpdate's status
classification (result === 'REFUSED' / 'DARWIN_UNAVAILABLE' / ...) therefore
never ran for any of those outcomes; every one collapsed into the generic
"Could not terminate the Desktop-owned remote serve for update." On a Darwin
remote this fires on every managed SSH update, since the script unconditionally
refuses to signal there.

Align the probe with this file's own established convention (used by
buildOwnedStaleTerminationCommand, pidIsOurDashboard, remoteProcessCreationTime):
always exit 0 and let stdout carry the status.
2026-09-29 20:56:59 -05:00
686f6c61
7ceda46ba4 fix(desktop): clear dead remote update markers on SSH startup
A dispatched hermes update that dies after backup/fetch left a marker
whose PID was gone, but startup still treated it as live. Unlink a
dead or recycled non-update PID and log the detached run's start/exit.
2026-09-29 20:56:46 -05:00
Siddharth Balyan
5dc1c084e9 fix(desktop): a reused tool call id no longer overwrites a finished tool row (#128009)
* fix(desktop): a reused tool call id no longer claims a finished tool row

A tool.start whose id repeats a call that already completed in the same
message was merged into that finished row: the args and error of the new
call overwrote the successful one, so a loaded skill rendered as a failed
one. The stable-id match now claims only an unresolved part, as
toolCallOwnerMessageId and applyStoredToolResultToParts already do. A
completion with no unresolved same-id part still lands on the finished
part when it repeats that part's result or carries none, so replayed and
metadata-only completions add no phantom row.

Refs NS-1029 B3.

* fix(desktop): a tool row shows only its own diff

The desktop kept a side store of inline diffs keyed by the raw tool call
id, and a tool row preferred that diff over the one on its own part. With
a reused id, the newest patch was stored under the shared id, so every row
that still carried that id showed the newest diff. Now that a reused id
gets its own row, that wrong diff became visible on the first row.

Every live completion already writes inline_diff into the part's
toolResultMetadata, and cold hydration never filled the side store. The
row now reads the diff from its own part, and the side store is removed.

Refs NS-1029 B3.
2026-09-30 01:49:32 +00:00
Austin Pickett
426853ab6a fix(desktop): ack inline-preview widget intents instead of truncating or dropping them
window.hermes.send() from a ::preview frame silently sliced prompts to 500
chars and silently dropped a second intent within the 1s throttle, so a
widget sending a JSON payload reported success while the agent got garbage
or nothing.

The parent now rejects over-length and throttled intents explicitly and
posts an ack back to the frame; send() returns a Promise of
{ ok: true } or { ok: false, error: too_long | throttled | invalid |
undelivered } (with maxLength / retryAfterMs). The cap and throttle stay;
nothing is truncated. The gate is a pure evaluateIntent() under test.

Covers the truncation, throttle, and delivery-ack findings of #118973; the
side-preview-pane bridge (finding 1) is untouched.
2026-09-29 21:45:55 -04:00
Faer1x
2f1d308850 fix(desktop): keep the first send when only the active runtime is retargeted
A background stream can change the active runtime ref while session.create
is in flight. That mismatch aborted every first send: no prompt.submit,
no stored row, and an empty tab. Abort only when route and selection
moved to a chat whose runtime is not the one create returned.
2026-09-30 03:16:01 +02:00
webtecnica
ac0cfa7db9 fix(desktop): add SSH keepalive to prevent tunnel drop after idle (#74998) 2026-09-29 20:13:52 -05:00
Brooklyn Nicholson
02e4118110 fix(desktop): distinguish transport failure classes in WS-ticket mint copy
The original 401-misclassification from #98647 (a rejected WS-ticket
mint reported as "Could not reach the remote Hermes gateway") was fixed
on main by #95701: the shared httpStatusError attaches err.statusCode
and gatewayTicketFailure routes 401/403 to the sign-in copy with
needsOauthLogin/isReauthRequired.

What remained live was the transport leg: resolveRemoteOauthTicket
wrapped EVERY non-auth mint failure - timeout, ERR_CONNECTION_REFUSED,
DNS failure, a reverse-proxy 502 - in one undifferentiated sentence
(remote-oauth-ticket.ts:38 passed a static transportMessage), so fleet
logs hid three different upstream causes behind identical copy
(~1,100 lines across 47 hours in the reporter's fleet, zero of which
named the actual failure).

- connection-config.ts: new exported gatewayTicketTransportMessage(error)
  names the failure class: timeout/unreachable/DNS -> "timed out or the
  host could not be resolved... check your network/VPN path";
  ECONNREFUSED -> "connection was refused... the gateway process is
  likely down"; anything else keeps the legacy one-liner.
- remote-oauth-ticket.ts: resolveRemoteOauthTicket derives its
  transportMessage from the cause instead of a static string. Auth
  classification is unchanged.

Refs #98647
2026-09-29 19:52:44 -05:00
Brooklyn Nicholson
d9f0a399a0 fix(desktop): gate the NVIDIA SwiftShader fallback on a witnessed GPU-process death, not the driver series (#124255)
The NVIDIA EGL fallback (added for the #40077 580-series crash, bounded to
{580} by #123213) forced every 580 host onto all-CPU SwiftShader
compositing. But the same driver point release breaks one host and not
another: 580.178.04 kills the GPU process where NVIDIA drives the display,
and renders fine on hybrid hosts whose session EGL lands on the iGPU
(#124255 reporter, psukez on #123203). The all-CPU fallback then turns a
known Chromium stuck-Button-observer leak (~0 cost with GPU compositing)
into a silent 4-9 core burn.

Replace the driver-major set with a behavioral probe (option A from the
#124255 decision prep): a Linux+NVIDIA host boots with hardware GL and a
booting marker; a GPU-process death before first window reveal (reasons
crashed / launch-failure / killed — #40077 died to Chromium's health-check
SIGTERM, exit_code=15) flips the marker sticky per app version + full
driver version and relaunches once with --use-angle=swiftshader. A healthy
boot marks ok; an app or driver update re-probes once. A leftover booting
marker (the 'GPU process isn't usable' FATAL abort winning the race)
engages on the next launch. HERMES_DESKTOP_NVIDIA_SWIFTSHADER still forces
on/off; HERMES_DESKTOP_DISABLE_GPU=0 still wins. Never
disableHardwareAcceleration() — that SIGKILLs the renderer on 580 +
Electron 40 (#40119).

Healthy 580 hosts get hardware rendering back; broken ones still reach the
desktop in at most one extra restart per app/driver update.

Follow-up to #123213. The in-app notice (#123598) and the warn-once
stuck-observer log (#124275) remain complementary and are not included.

Fixes #124255
2026-09-29 19:46:14 -05:00
Brooklyn Nicholson
7327624d35 fix(desktop): trim trailing punctuation from bare MEDIA tag captures and reject degenerate paths
Bare-branch MEDIA: captures like 'open MEDIA:/tmp/a.pdf.' dragged the
trailing period into the file path, and degenerate captures ('MEDIA:',
'MEDIA:...') produced dead file links. Split trailing punctuation off
bare (unquoted) captures only, require a plausible media path before
linkifying, and escape %/?/# in local file URLs.

Co-authored-by: 686f6c61 <github@00b.tech>
Co-authored-by: professorpalmer <professorpalmer@users.noreply.github.com>
2026-09-29 19:45:22 -05:00
Nagisa-3000
5b7fed2700 fix(desktop): keep quick-entry options readable in dark themes 2026-09-29 19:44:52 -05:00
Brooklyn Nicholson
056da73d0b fix(desktop): stop silently dropping long and multi-line clarify choices
normalizeChoices dropped any choice over 200 chars or containing a
newline while choices_offered kept reporting it, so users answered
among missing options and the agent never learned what was actually
shown (#124127).

The desktop filter now matches the tool contract: the cap is
MAX_CHOICE_CHARS=8000 (the value the clarify tool enforces at the
source, measured on the bare text so the '(Recommended)' label doesn't
count against the limit) and newlines are kept so option reasons can
wrap. Long choice text wraps in the card rather than disappearing.

Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
2026-09-29 19:44:05 -05:00
Brooklyn Nicholson
a7c2df3846 test(desktop): keep spawn-env coverage after the onboarding test removal
#126139 deleted guest-onboarding-flag.test.ts ahead of the guided-onboarding
rewrite, which also took the only coverage of desktopBackendSpawnEnv — the
assertion #118114 added for the stay-alive stamp included. Restore a focused
test file: the launch-decision stamping invariants plus the unconditional
GATEWAY_ON_ALL_ADAPTERS_DOWN=stay_alive stamp (#118080).
2026-09-29 19:33:09 -05:00
xiaodu
91617521c6 fix(gateway): stay alive on all-adapters-down when no service manager supervises
A gateway whose last messaging adapter hits a retryable fatal error exits
with the failure verdict (EX_TEMPFAIL) so a supervising service manager
restarts it. The desktop app launches `hermes serve` directly with no
supervisor: the exit only severs the UI's websocket connections and drops
in-flight assistant messages, which then 'disappear' from chat (#118080 —
334 fatal events over 6 days on one profile, mostly email IMAP fetch
failures marked retryable).

Add gateway.on_all_adapters_down: exit | stay_alive (default exit — zero
behavior change for service-managed deployments). At both stranded-exit
sites in gateway/run_adapters.py, the stay_alive policy keeps the process
running (reconnect watcher owns recovery) instead of setting
_exit_with_failure; non-retryable adapter loss still exits. The desktop
launcher stamps GATEWAY_ON_ALL_ADAPTERS_DOWN=stay_alive last into the
child env (same rule as HERMES_GUEST_ONBOARDING); older runtimes ignore
the unknown variable and keep the historical behavior.

Fixes #118080
2026-09-29 19:33:09 -05:00
kokhlo
9e63cdc4b8 fix(desktop): hide community catalog rows that only share a name with an installed skill
Skills install by name, so the installed marker matched any same-named
feed row. That identity only holds for first-party namespaces; community
feeds carry distinct skills reusing popular names (docx, xlsx, pdf), and
every lookalike rendered with the installed checkmark and an ON/disabled
switch, and passed the Installed filter. Such a row is neither the
installed skill nor installable beside it, so the browser now drops it
while official rows keep the existing name match.
2026-09-29 19:32:19 -05:00
Adolan
77b727551b fix(desktop): open the rejected provider's key from Update API key 2026-09-29 19:31:49 -05:00
rodricksz4h5
961c79220a fix(desktop): route a session with no provider to setup, not a dead-end toast 2026-09-29 19:30:43 -05:00
Brooklyn Nicholson
f886d133fe fix(desktop): treat a boot-window ok:false as a hydration race, not a verdict
The renderer already survives a readiness round where NEITHER probe answers
(transport fallback). But the #124939 flash was a round where runtime_check
ANSWERED ok:false ~2s after an update restart: the backend had booted, its
external secret source (BWS-backed .env) had not hydrated yet, and the probe
resolved the fallback chain to a tail provider with no key — so the store
wrote configured:false, wiped the onboarded cache, and the onboarding overlay
flashed 'No usable credentials found for openrouter' on a fully configured
install.

Add a boot-generation clock ($gatewayBootGeneration, bumped by setup.ready
and gateway switch/wipe) and a 15s grace: inside the window, an answered
ok:false against an already-configured install is preserved as not-ready and
re-checked on the setup.ready tick, instead of being written down as a
credential verdict. Outside the window (or an unconfigured install) the
downgrade still lands, so a genuinely broken provider still surfaces.

Co-authored-by: sly321-pixel <239388517+sly321-pixel@users.noreply.github.com>
2026-09-29 19:30:28 -05:00