Skip to content

merge: upstream/main v0.20 sync — unlocks desktop remote auth (DAN-2485) - #148

Merged
dizhaky merged 10000 commits into
mainfrom
sync/upstream-20260805
Aug 5, 2026
Merged

merge: upstream/main v0.20 sync — unlocks desktop remote auth (DAN-2485)#148
dizhaky merged 10000 commits into
mainfrom
sync/upstream-20260805

Conversation

@dizhaky

@dizhaky dizhaky commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Merges upstream/main (v0.20.0 lineage) into the fork: 138 ahead / 0 behind after this PR
  • Unlocks the v0.17 desktop remote-auth protocol (dashboard_auth/ws_tickets, /auth/* mounts) so the native app can pair with the mfc1 gateway
  • Config auto-migration v30 → v33 present; gateway run/Slack socket/cron/kanban/pairing verified present
  • uv.lock restored + regenerated (d73119d)

Deploy plan (after merge)

  1. Backup mfc1 ~/.hermes/config.yaml, uv sync on mfc1, restart hermes-gateway + hermes-dashboard
  2. Verify Slack reconnect + api 8642 + dashboard 9119 + cron ticker
  3. Desktop v0.17 sign-in against mfc1 + pairing approve

Test plan

  • Gate: CI green; local smoke hermes_cli.config imports OK

Refs DAN-2485

🤖 Generated with Claude Code

kshitijk4poor and others added 30 commits August 3, 2026 20:29
… effect

CI-caught: cron-jobs-section had an extra blank line between sorted imports; use-message-stream's visibility-flush effect assigns flushHandleRef.current=null inside a useEffect (legitimate timer-clear, not an atom mirror) — eslint-disable-next-line per the rule's documented convention.
Retarget NousResearch#73639 onto the SessionDB mixin split (hermes_state_common /
hermes_state_schema). Fresh installs create UPDATE OF content/tool_*
triggers; existing broad AFTER UPDATE triggers are inspected and
replaced under schema init without an FTS rebuild (WHEN clauses already
guarded content correctness; OF skips non-content status writes that
saturated disk I/O on large state.db).

Tests: tests/test_fts_update_of_narrowing.py (4)
_ensure_fts_cjk_schema never raises on OperationalError; post-condition
after dropping messages_fts_cjk_update now requires a narrowed UPDATE
trigger or durable fts_cjk_stale + unavailable. Covers the production
soft-fail path the raise-only handler missed.
Simplify-pass fold: to_drop names come from the literal update_names\nallowlist via IN binding, so the [A-Za-z0-9_]+ fullmatch could never\nfail — and if it somehow did, its `continue` would miscount (the\nskipped trigger stayed in len(to_drop)/the log while CREATE TRIGGER\nIF NOT EXISTS silently kept the broad variant). Delete the guard and\nits function-local re import; keep the invariant as a comment.
## Summary
- Normalize OpenViking endpoints through `is_always_blocked_url` and fall back to the default local endpoint when poisoned.
- Keep intentional loopback / LAN self-host working.
- Add focused unit tests.

## Salvage / credit
Memory-provider endpoint floor sibling of RetainDB/Supermemory always-blocked hardening (avoids over-broad NousResearch#4984-style private-IP bans).

(cherry picked from commit 8fa607d)
`_start_local_openviking_server()` spawned `openviking-server`
unconditionally. Both callers — `initialize()` and the runtime
unreachable handler — reach it from a health probe, and that probe can
time out client-side while the server is up and serving. The spawned
process then loses the data-directory lock and exits immediately with
`DataDirectoryLocked`; because the probe keeps timing out, the cycle
repeats every cooldown window (~5 min observed).

The existing 30s `_failed_refresh` cooldown paces the loop but cannot
stop it, since it expires while the underlying condition persists.

Probe the target host:port before spawning and treat an occupied port as
already-started. This guards both call sites at their single convergence
point. The probe deliberately tests only that a listener owns the port —
enough to know a second server would lose the lock — and says nothing
about that listener's health.

The parse/probe now precedes the PATH lookup, so a reachable server is
reported as running even when `openviking-server` is not on PATH.

Fixes NousResearch#74846

(cherry picked from commit b49427d)
The provider used to disable OpenViking permanently when the server was
unreachable. That was fixed: `_ensure_client()` now reconnects lazily,
with a 30s cooldown gate in `_ensure_client_locked`.

Only one of the seven user-facing warnings was updated to match. The
other six still told the user memory was "disabled for this Hermes run",
which is no longer true — every one of those paths is retried on the next
access. A user who reads the old message has no reason to retry, which is
very likely how NousResearch#5721 ("never recovers") came to be filed against
behaviour that already recovers.

All six sites were traced to confirm none is terminal for the run: the
`initialize()`-time and waiter-thread failures never arm `_failed_refresh`
(only line 2439 does), so they retry on the very next access with no
cooldown at all.

The replacement wording deliberately omits the "(after cooldown)"
parenthetical used at the already-correct site — that detail is only
accurate where `_failed_refresh` was just armed. The neutral phrasing is
true at all six.

Also promotes two clause separators to periods to avoid "…; …disabled;"
collisions.

(cherry picked from commit 8346403)
`_committed_session_ids` is a permanent per-sid latch, and
`_session_needs_commit` checks it before the turn counter by design — a
racing sync_turn can re-increment `_turn_count` after commit+reset, so
the guard must win to stop a double-commit.

That is correct for a session being left behind. It is wrong for one
that keeps its id. `compress_context()` commits before rewriting the
transcript in both modes, and with `compression.in_place: true` (the
default) `on_session_switch` receives the same id and does not rotate.
The latch then rejects every later commit for a still-live session — the
next compression, /new, normal session end, startup recovery — so every
post-compression turn is silently never extracted.

Rotation mode is unaffected because a fresh child id is minted and
starts clean, which is what confirms the latch's intent was only ever to
dedupe the departing id.

Clear the latch when compression completes without rotation. Turns
arriving after that point are genuinely new, and this is a defined
moment rather than a race. The rotation path is untouched, so the old
id stays latched and its _finalize_session_async still dedupes against
the compression commit.

Fixes NousResearch#74695

(cherry picked from commit d1e5c3d)
Review feedback: the previous test called _mark_session_committed
directly, so it verified the guard's behavior but not the wiring that
sets it — a future break in the commit_memory_session -> same-id
compression-boundary path would not be caught.

Add a lifecycle regression that drives the real sequence: on_session_end
commits through the actual path, on_session_switch(same id,
reason="compression") crosses the boundary, sync_turn records a genuinely
new turn, and a second on_session_end must produce a second commit POST.

Without the fix it fails showing exactly one commit call, which is the
reported data loss: every turn after the first compression is dropped.
The rotation and /undo tests stay as scope guards.

(cherry picked from commit 0ca5a33)
…nViking and RetainDB

OpenViking is_available() only consulted env vars and use_ovcli_config, so an
endpoint saved to config.yaml (e.g. by the Dashboard) reported needs_config;
_resolve_connection_settings() likewise never folded config.yaml's non-secret
fields into its chain. RetainDB initialize() read base_url/project from the
environment only, ignoring the values the Dashboard writes to config.yaml.

Both now resolve non-secret fields as env -> (ovcli ->) config.yaml -> default;
secrets still come from the environment. Adds regression tests for both.

Fixes NousResearch#68209

(cherry picked from commit dca57915b97b5705b30927a062e1d0f2f23d3841)
…s as fallback

_recall_config() previously read all settings (recall_limit, score_threshold,
recall_resources, etc.) exclusively from environment variables. This forced
users to store behavioural configuration in .env, violating the Hermes
convention that .env is for secrets only.

The infrastructure to load config.yaml -> memory.openviking was already in
place via _load_hermes_openviking_config(), but _recall_config() never
called it.

Fix: call _load_hermes_openviking_config() and pass its values as the
default parameter to _env_int/_env_float/_env_bool. Env vars still override
config.yaml values, preserving backward compatibility.

Closes NousResearch#62540

(cherry picked from commit 6aadf12)
…HOME tests

Add three tests to TestOpenVikingConfigSchema:

1. test_recall_config_reads_from_config_yaml — writes memory.openviking
   settings in config.yaml and verifies _recall_config() consumes them.

2. test_recall_config_env_overrides_config_yaml — writes both config.yaml
   and OPENVIKING_RECALL_* env vars, verifies env takes precedence.

3. test_recall_config_partial_config_yaml — partially populated config.yaml
   falls back to defaults for omitted keys.

All 46 openviking_plugin tests pass (43 existing + 3 new).

(cherry picked from commit b8d7834)
Review follow-up for salvaged PR NousResearch#76782. Three setup-wizard
validation functions called _normalize_openviking_url outside their
try/except blocks. Since _normalize_openviking_url now raises
_OpenVikingEndpointError for blocked or malformed endpoints, an
invalid endpoint would crash the wizard instead of returning a
friendly (False, message) tuple.

- _validate_openviking_auth: move _normalize_openviking_url inside try
- _validate_openviking_root_access: same
- _validate_openviking_setup_values: catch _OpenVikingEndpointError explicitly
- Remove dead ternary in _normalize_openviking_url safety check (candidate
  always has http/https scheme by that point)
- Replace redundant float('-inf') < x < float('inf') with math.isfinite()
  in _setting_float; drop the redundant infinity check from _setting_int
  (is_integer() already rejects inf/nan)
…-cicav

chore: contributor email mapping for cicav (legacy noreply form)
…ckup/vendor dirs

SubdirectoryHintTracker re-injected identical context files whenever the same
AGENTS.md was reachable through more than one path. Symlinked shared
workspaces, hardlinks, and timestamped backup copies all alias a single file,
so a normal session could ship the same 8KB of instructions two or three
times. Nothing deduped it and nothing excluded directories that only ever
hold copies.

Two changes:

* Track a sha256 of every injected hint body. Repeat content is skipped, and
  the working directory's own context file is seeded at construction so the
  copy prompt_builder already loaded at startup is never sent again.
* Skip directories that hold copies rather than authoritative context
  (backups, node_modules, venv, site-packages, .git, .Trash, vendor, caches).
  Screening is relative to working_dir, so a project that legitimately lives
  under vendor/ keeps discovering its own subdirectory hints.

Measured on a real session that touched a symlinked shared workspace:
3 injections / ~24,000 chars before, 1 injection / 8,112 chars after.

14 new tests cover symlink aliasing, byte-identical copies, working-dir
seeding, distinct content still being injected, each excluded directory name,
excluded ancestors, and the working-dir-inside-excluded-name case.
Re-derivation of NousResearch#23254 (@devsart95) on today's flush loop. The turn
flush in _flush_messages_to_session_db wrote one BEGIN IMMEDIATE
transaction per message row; a typical agent turn (user + assistant +
tool results) paid 3-8 transactions -- and, off WAL (the default on
macOS while the WAL-reset guard is active), 3-8 fsyncs -- per turn.

Adds SessionDB.append_messages_batch: same row shape as append_message
(shared _prepare_message_row serializer + _MESSAGE_INSERT_SQL column
list, so the two writers cannot drift), same compression-lock and
compression-closed guards, one aggregated session-counter UPDATE, one
transaction for the whole batch. Row serialization stays outside the
write lock.

The flush loop now collects the turn's new rows and writes them in one
call. All-or-nothing pairs exactly with the persisted-marker stamping:
on failure no rows landed and no markers were stamped, so the next
flush re-writes the whole tail (same recovery contract as before,
minus the partial-prefix case that could double-count).

Measured (same harness, 5-message turn, journal_mode=DELETE,
synchronous=FULL): 2.32ms -> 0.83ms median per turn flush (64% faster,
5 fsyncs -> 1). On WAL the win is smaller but the atomicity fix holds.
Sibling sites of the per-message flush pattern: both branch-seed
paths (session.branch in methods_session.py and the lazy seed persist
in server.py) copied the parent history row-by-row -- one transaction
per row, and a branch seed can be hundreds of rows. Route both through
SessionDB.append_messages_batch. The server.py path also gains real
atomicity: _branch_seed_persisted assumed every row landed, which the
per-row loop could not guarantee.
…rites

The flush now goes through append_messages_batch; MagicMock-based
assertions and barrier fakes that hooked append_message observed
nothing (the flush's try/except swallowed the AttributeError). Assert
on the batch payload instead.
… share guards, chunk seeds

Simplify-pass folds on the NousResearch#23254 salvage:

- REUSE (HIGH): append_messages_batch now delegates row serialization to
  the pre-existing _insert_message_rows helper (already shared by
  replace_messages / archive_and_compact / portability import) instead
  of adding a third serialization path (_prepare_message_row +
  _MESSAGE_INSERT_SQL are gone). One row-writer for every multi-row
  path; the row-ID return was consumed by no production caller, so the
  batch returns the inserted count.

- QUALITY (HIGH): the compression-lock + compression-closed admission
  guards are extracted into _check_transcript_write_guards, shared by
  append_message and append_messages_batch (previously duplicated 23
  lines that had already needed targeted fixes, NousResearch#74478). The role-gated
  reasoning filtering is no longer duplicated in run_agent.py — it
  lives at its one site inside _insert_message_rows.

- EFFICIENCY (MEDIUM, measured): unbounded seed copies hold one BEGIN
  IMMEDIATE for seconds (10k rows ~= 2.4s; FTS triggers dominate) and
  monopolize the in-process write lock. append_messages_batch grows a
  chunk_rows param; all seed/copy call sites use chunk_rows=500. Same
  recovery semantics as the old per-row loops, bounded lock holds.

- REUSE (MEDIUM): the two remaining per-row branch-copy loops found by
  the pass (gateway/slash_commands.py /branch, hermes_cli
  cli_commands_mixin.py branch) are converted to chunked batches too
  (AsyncSessionDB's generic to_thread forwarder covers the async site).

Turn-flush benchmark unchanged after the refactor: 2.43 -> 0.87 ms
median per 5-message flush (64% faster).
…pend_messages_batch

CI-caught: test_verification_stop_caching and test_tui_gateway_server::test_native_vision_turn_persists_a_renderable_image_ref both assert on append_message.call_args, but the flush loop now calls append_messages_batch. Same class of test-fake fallout fixed in 5 other files — these two were missed.
…#38491)

Re-derivation of NousResearch#38491 by @stremtec onto current main (the original is
10,119 commits behind; the hook moved into ui-tui/src/app/). The hook
returned a fresh object literal every render, defeating memoization in
useMainApp's consumers; useMemo over the (all-useCallback-stable)
handles makes the return referentially stable.

Dep array covers ALL nine returned handles incl. trimTail (the
re-derivation initially omitted it - stale-closure class).
OutThisLife and others added 21 commits August 5, 2026 10:07
A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.

Supersedes NousResearch#70296 and NousResearch#57878.

Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
- perfectionist/sort-imports in store/wake-word.ts
- contributors/emails mapping for drew@kainotomic.com -> appletechie
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ch#79494)

A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.

Supersedes NousResearch#70296 and NousResearch#57878.

Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
…7705)

* feat(desktop): shared pane-strip primitives — one bar, one glyph, one close menu

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

URL targets now share one url:browser id: openPreview re-fronts the tab and
swaps its target, and the pane rebuilds its webview against the new address.
Files and artifacts keep per-identity tabs. Restored storage rekeys old
per-address rows and keeps only the most recent.
The GUI now passes client_capture: true on wake.start, wake.status, and the
post-voice re-arm; update the store and slash-handler tests to the new
param shape. 123/123 pass locally.
PDFs were classified as generic binary/text previews, rendering raw %PDF
bytes locally and failing entirely for remote-only files. Classify PDFs as
their own preview kind, load bytes through the existing local/remote
filesystem bridge, convert them to revocable Blob URLs for Chromium's
embedded viewer, migrate persisted pre-PDF tabs at restore, and retry
restored previews when the active filesystem connection changes.

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

Supersedes NousResearch#76565.

Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh>
…reaming (NousResearch#79491)

* feat(wake): client-capture wake word for remote desktop

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

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

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

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

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

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

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

* perf(desktop): coalesce wake.feed frames

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

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

---------

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

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

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

Fixes NousResearch#47359
Fixes NousResearch#58764

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

fix(desktop): render remote PDFs in preview rail
* feat(agent): read_preview — the desktop-gated tool that reads the in-app browser

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

* feat(gateway): preview.read blocking bridge

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

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

preview-reader.ts is the preview analog of the terminal's buffer registry: the
URL pane registers a page reader (webview executeJavaScript → title + visible
innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows
the text (24k cap per read), and answers file/artifact tabs with identity plus
a note pointing at the tool that reads that content directly. The gateway
event handler answers preview.read.request beside terminal.read.request.
…tes (NousResearch#79513)

* feat(update): emit an action-scoped terminal receipt from hermes update

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

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

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

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

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

Fixes NousResearch#47359
Fixes NousResearch#58764

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

---------

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…lay-skill-metrics

feat(observability): aggregate bounded skill metrics
…lay-client-dimensions

feat(observability): add Relay client resource metrics
…lay-install-activation-metrics

feat(observability): add Relay active install metrics
Strategy: -X theirs (upstream wins all conflicts). Dan's custom fork
commits (memgw provider, slack wiring, email HTML, cbm hooks,
CI workflows, security redactions) cherry-picked back in follow-ups.
Deps bumps left to upstream.

# Conflicts:
#	acp_registry/agent.json
#	gateway/platforms/slack.py
#	gateway/platforms/telegram.py
#	tests/cron/test_cron_profile.py
#	ui-tui/package-lock.json
#	ui-tui/packages/hermes-ink/package-lock.json
#	web/package-lock.json
…2485)

Co-Authored-By: Claude <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

DAN-2485

Comment thread apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts Fixed
Comment thread apps/shared/src/skill-scaffold.ts Fixed
Comment thread apps/shared/src/websocket-url.ts Fixed
Comment thread apps/desktop/scripts/perf/scenarios/session-switch.mjs Fixed
Comment thread agent/agent_init.py Fixed
Comment thread agent/agent_init.py Fixed
Comment thread agent/bedrock_adapter.py Fixed
Comment thread agent/bedrock_adapter.py Fixed
Comment thread agent/bedrock_adapter.py Fixed
Comment thread agent/bedrock_adapter.py Fixed
16 conflicts resolved by taking sync-branch (v0.20) version:
- 8x shared_metrics/relay_shared_metrics observability (add/add)
- uv.lock (clean v0.20 lockfile; main's had duplicate-key bug from #146)
- package-lock.json x2, ui-tui/package.json, apps/desktop/package.json
- 3x test files + 1 smoke script

uv lock --check passes (252 packages, 0 duplicate keys).

Co-Authored-By: Claude <noreply@anthropic.com>
@dizhaky
dizhaky merged commit e930f18 into main Aug 5, 2026
44 of 57 checks passed
@dizhaky
dizhaky deleted the sync/upstream-20260805 branch August 5, 2026 20:58
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.