Commit Graph

23733 Commits

Author SHA1 Message Date
Ben Barclay 2f77ca1993 fix(relay): reader without a socket settles pending waiters instead of asserting
_read_loop opened with 'assert self._ws is not None' — an exit that
escaped BEFORE the finally that fails pending futures, contradicting
the 'fails pending on ANY exit path' invariant the hardening commit
established. Production currently assigns _ws before scheduling the
reader, so this was latent, but any future lifecycle change hitting it
would strand every in-flight waiter for the full outbound timeout with
only an AssertionError in the logs.

Turn it into a guarded early-return INSIDE the try: the reader logs
the lifecycle bug and unwinds through the same finally as every other
exit, settling all waiters. Regression test drives _read_loop with
_ws=None against a registered pending future.
2026-08-20 14:13:18 +10:00
Ben Barclay ca3438d395 fix(relay): gate sends on the socket handle, not supervisor state
The mid-redial fail-fast guard used 'supervisor task not done' as the
definition of the redial window, and that signal is wrong from both
directions:

- Too narrow: the wedge it fixed also occurs on reader exits that arm
  NO supervisor (terminal 4401 revocation, reconnect=False) — those
  stayed wedged.
- Too broad: _reconnect_loop -> _dial_and_start installs the fresh
  socket and starts its reader, THEN awaits one hello send per fronted
  identity before the supervisor unwinds. Through those awaits the
  transport is fully live, yet the guard rejected every send as
  'reconnecting' — refusing real traffic on a healthy socket.

With the previous commit the reader clears _ws on unexpected exit, so
the existing 'is None' check now covers the entire outage window
honestly: _ws is the single liveness signal. Drop the supervisor-state
guard.

The redial-window test now drives the real sequence (reader exit arms
the supervisor and clears _ws) instead of hand-crafting a stale-_ws
state the transport can no longer reach, and a new test pins the
post-dial window: a send issued while the supervisor is still
unwinding past a live socket must reach that socket (fails with the
guard reinstated).
2026-08-20 14:12:33 +10:00
Ben Barclay 52f9979a6c fix(relay): reader clears the dead socket handle on unexpected exit
Two reader-exit paths arm NO reconnect supervisor: a terminal 4401
revocation (deliberately never re-dials) and reconnect=False
transports. On both, _ws kept pointing at the dead socket after the
reader unwound, so the 'is None' liveness guard reported connected and
a send registered a future nothing could ever resolve — the full
_outbound_timeout_s (~30s) wedge this PR exists to eliminate. The
revocation case is the sharpest: the fatal-error notification that
path emits is itself an outbound send, so it ate the stall.

Null the handle in the reader's finally, identity-guarded (only if _ws
still points at the socket THIS reader served) so a supervisor re-dial
that already installed a fresh socket is never clobbered, and gated on
not _closing so disconnect() keeps sole ownership of teardown.

This also makes _ws the single honest liveness signal for the redial
window itself — groundwork for retiring the supervisor-state send
guard, which misreads that window from both directions.

Regression tests cover both uncovered paths, asserting _ws is cleared
and a post-drop send fails fast; both wedge (fail) with the finally
reverted.
2026-08-20 14:10:54 +10:00
Ben Barclay 8772702503 test(relay): dedupe regression tests drive the real wire-decode path
The dedupe tests validated hand-built events only, so a key that read a
field the production event type doesn't have still went green — and the
'all 7 new tests fail against base' mutation claim didn't hold either
(the fail-open and distinct-message tests pass against base because a
no-op dedupe trivially satisfies both).

Add a wire-level class that decodes a connector frame with
_event_from_wire and dispatches it through _on_inbound — the exact
production path — asserting: a decoded event yields a dedupe key at
all, a re-delivered frame is dropped (fresh decode each time, so
identity must come from the key, not the object), and identical
chat/message ids on two different platforms are NOT conflated
(Phase 1.5 multiplex).

Mutation-checked: with the previous event-shape key reinstated, the
wire tests fail (3 failed); with the fix, all 7 pass.
2026-08-20 14:08:36 +10:00
Ben Barclay 22dfdc1c56 fix(relay): dedupe key reads chat identity from event.source, keyed per platform
The dedupe key read event.chat_id — a field MessageEvent does not have
(chat identity lives on event.source.chat_id; see how every other read
in this adapter resolves it). getattr defaulted to None, so
_inbound_dedupe_key returned None for EVERY production event: the
fail-open branch always taken, the seen-set permanently empty, and a
replayed inbound still re-ran the whole turn. The tests passed because
their SimpleNamespace events carried a top-level chat_id no production
code path produces.

Read the chat id from event.source, and join the underlying platform
into the key: one relay adapter fronts several platforms (Phase 1.5
multiplex), and two platforms' numeric chat/message ids must not
collide into one replay identity.

The test event factory now builds real MessageEvent/SessionSource
objects in the wire decoder's shape, so the replay and bounded-set
tests fail against the broken key instead of green-lighting it.
2026-08-20 14:07:11 +10:00
Victor Kyriazakos b5f95d0890 fix(relay): drop replayed inbounds — bounded dedupe on (chat_id, message_id)
Live-canary finding #3 (Alice, staging): the relay inbound leg is
at-least-once. On WS re-handshake the connector replays its durable
per-instance buffer; a long multi-tool turn (60-100s) straddling a quiet
socket drop got its ORIGINAL inbound replayed after the turn finished,
re-running the entire turn — the user saw the final answer posted 2-5x
(each a separate execution, hence slightly different texts). Receipts:
same msg text at history=0 in back-to-back sessions 121647/121840, no
Slack-side retry on the connector (envelope dedupe never fired).

Consumer-side idempotency: bounded FIFO seen-set (512) keyed by platform
message identity; events without a message_id never dedupe (fail-open —
dropping a real message is worse than rerunning one). No wire change;
contract v1 untouched.

Transplanted-from: victor-fork/feat/relay-slack-live-cards@73ce04ae75 (extracted for the rc.4 relay-fixes train; tests moved to a standalone file with no live-cards dependencies)
2026-08-19 12:24:10 +00:00
Victor Kyriazakos 534f8d4fb0 fix(relay): WAN-friendly keepalive tuning (ping_interval=30, ping_timeout=60)
Customer gateways cross WAN paths to the connector; the websockets
library defaults (ping 20s, pong deadline 20s) close the socket with
1011 keepalive ping timeout under transient latency or event-loop
stalls — the trigger of the Coatue 2026-08-18 incident. 60s tolerates
stalls while still detecting a genuinely dead link within ~90s worst
case. Set explicitly at both connect() call sites (with and without
auth headers) so the tuning is visible and pinned by test rather than
inherited from library defaults.
2026-08-19 00:22:01 +00:00
Victor Kyriazakos a7946f6502 fix(relay): fail sends fast while the reconnect supervisor is mid-redial
Between an unexpected close and the supervisor's successful re-dial,
self._ws still points at the DEAD socket, so the existing None-check
does not cover the backoff window: a send registered a future no reader
could resolve and blocked the caller for _outbound_timeout_s.

A live supervisor task is exactly the redial window (the loop returns
when a dial succeeds and its fresh reader takes over), so no new state
is needed: when self._supervisor is not done, return the error dict
immediately ({success: False, error: reconnecting}). Callers already
handle failed sends; a fast, honest failure beats a 30s wedge
(Coatue 2026-08-18).
2026-08-19 00:21:35 +00:00
Victor Kyriazakos d2975f4219 fix(relay): _read_loop fails in-flight pending futures on any exit
The reader is the only thing that can resolve a pending outbound_result
future. When the socket dropped unexpectedly (vs a deliberate
disconnect(), which already failed pending), every in-flight
_request_response waiter blocked the full _outbound_timeout_s (~30s) on
a future no reader could resolve.

Coatue incident 2026-08-18: a 1011 keepalive ping timeout close left
final-send and error-notification calls wedged against the dead socket,
holding sessions active while the connector replayed inbound backlog.

Fail every not-done pending future from _read_loop's finally with the
dict shape callers expect ({success: False, error: connection lost}) —
never an exception on the outbound path — then clear the map. list()
snapshot avoids mutation-during-iteration from woken waiters' finally
pops.
2026-08-19 00:21:18 +00:00
Teknium a77ee88ce2 feat: Bot Mode avatars default to deterministic blob faces drawn from the agent's name
New agents now get a blobatar — a deterministic soft-body face generated
from the bot's name (same name, same face, forever) — as the default
shapes mode, with full manual control:

- Face follows the name live while typing in New Agent
- Randomize re-rolls the seed; Lock face pins the current one so a later
  rename can't change it (Unlock returns to name-following)
- Any of the six silhouettes (round/organic/boxy/nub/cloud/sun) can be
  pinned via frozen-per-major trait positions while the rest stays
  name-derived
- Classic geometric shapes remain one click away, and existing bots keep
  their stored looks untouched

Wiring: blobatar@0.2.0 (zero deps, ~3.7KB) exported through the plugin
SDK (blobatarSvg / Blobatar), feature-detected in plugin.js with a
legacy-shape fallback for older desktops. Blob shape strings are
'blobatar[:seed[:kind]]' inside the existing meta.shape field, so
persistence, cross-machine ui_meta sync, and the roster's PNG backfill
(data-bot-face tag preserved) all work unchanged.
2026-08-18 15:54:24 -07:00
Teknium f8767d1e71 perf(desktop): durable transcript-tail cache — bot wakes paint at ~0ms
Second layer of the "make hydration feel instant" work (on top of the
paint-first wait): persist a bounded tail (40 msgs / 256KB / 50-session
LRU) of every reconciled transcript in localStorage, keyed by durable
stored-session id.

- Cold resume paints the cached tail immediately — the wake is visually
  complete before any network I/O; the REST prefetch / runtime resume
  reconcile the authoritative transcript over it when they land.
- The cached paint is DISPLAY-ONLY: reconciliation treats the view as
  empty (viewMessagesForReconcile), so authoritative content replaces the
  provisional paint wholesale — no grafting onto stale rows. Failure
  latches also treat it as empty, so a cached paint can never mask a
  genuinely stranded resume; a resume that proves the session empty rolls
  the paint back and drops the poisoned entry.
- Saves happen only post-reconcile (cold path + warm activate path);
  deletes drop the entry; a gateway/mode re-home wipes the cache (another
  backend can recycle stored ids).

Sabotage-proven: disabling the cache load fails 5/7 cache tests; the
display-only contract is covered by the existing resume reconciliation
suite (663 tests green).
2026-08-18 15:52:39 -07:00
Teknium 5ce09b3c1e perf(desktop): Bot Mode wakes paint-first — transcript paint completes the
wake instead of the full runtime boot (#89206 class)

zero trust's third bundle (on ae6578af, both prior fixes present) showed the
remaining failure: cold profile backends on slower Windows machines take
47-120s to fully boot, while the wake path's fixed budgets (20s hydration,
~15s resume retries) raced the whole boot and lost — "errors waking up BOTS"
while the backend came up healthy moments later.

Rather than raising timeouts, make the wake cheap:

- waitForFocusedSessionHydration: a history-bearing chat is hydrated when
  the persisted transcript is PAINTED on the right session. The REST
  prefetch delivers that seconds after the backend's HTTP is up; the full
  runtime resume (agent build, MCP discovery, 114-skill load) keeps warming
  in the background and binds the composer when it lands. Only an
  expected-empty chat still waits for the runtime (nothing to paint).
- On hydration timeout, log a [bot-wake] phase breakdown (activation ms,
  hydration ms, which conditions were unmet) to the renderer console so the
  next support bundle pinpoints the slow phase directly.
- web_server: flush the headless "listening" line — block-buffered on the
  Desktop's piped stdout, it surfaced minutes late and made boots look far
  slower than they were in support bundles (the 120s "gap" in this bundle
  was partly this artifact).

Sabotage-proven: restoring the runtime-gated wait fails the new paint-first
test by timing out — the exact field shape.
2026-08-18 15:52:39 -07:00
Teknium b359db72ee feat(bot-mode): group chats accept image attachments every responding bot sees
Group rooms were text-only on the ingest side: the composer and
runGroupChatMemberTurn submitted prompt.submit {text} with no attach step,
so a user screenshot could never reach the members' models (community
report from Osiris). The gateway already ships the staging pipeline
(image.attach_bytes -> attached_images -> next prompt.submit) and the 1:1
canonical chat uses it — this wires the group surface to the same path.

- Composer + thread reply boxes: attach button, Ctrl/Cmd-V paste, pending
  chips with preview/remove, image-only sends allowed.
- Attachments are downscaled (long edge 1568px) and stored on the room-log
  entry, so reloads keep showing what members were shown.
- Turn drive stages the delta's images into EVERY responding member's own
  per-group session via image.attach_bytes before its prompt.submit —
  works cross-connection through requestForBot; a failed attach degrades
  that member to text-only rather than failing the turn. Watermarks
  guarantee an image is staged at most once per member.
- formatGroupChatLine names attachments ([attached image: name]) so the
  transcript delta and the staged pixels line up for every viewer.
- Room log renders attached images on user entries.

Tests: 6 new vm-harness tests (fan-out staging order, mention-scoped
attachment routing, image-only sends, no re-attach across turns,
transcript naming, invalid-attachment degradation); 276 total pass.
2026-08-18 15:05:15 -07:00
Teknium c70e15251d fix(tests): goal-verdict tests stop flaking when SessionDB init overruns the loop-thread grace window
Third CI hit today for tests/gateway/test_goal_verdict_send.py (twice on
salvage PRs, once on main's own push run), always the same shape:
adapter.sends == [] after the full drain.

Mechanism (reproduced, not log-read): the tests call GoalManager.set() on
the event-loop thread. _get_session_db() refuses to construct SessionDB on
a loop thread (loop-liveness guard from the 2026-08-14 crash-loop fix) and
waits only _DB_BOOTSTRAP_LOOP_WAIT_S=0.25s for the background bootstrap.
On a loaded CI runner the init overruns that window, set() degrades to a
silent no-op by design, the goal never exists, and the continuation path
correctly does nothing — so no amount of drain-waiting helps (the #88975
de-flake addressed a different, downstream race).

Fix: the hermes_home fixture pre-warms the SessionDB cache from its sync
context (direct construction path), so the loop-thread set() always finds
a cached DB. Production behavior untouched.

Proof: injecting a 0.4s-slow SessionDB.__init__ reproduces the exact CI
failure on the old fixture and passes 2/2 with the pre-warm.
2026-08-18 15:04:45 -07:00
Jack Lau f0f4f29e27 docs(desktop): state one fallthrough contract at the agent seam
The comment above ensureGatewayAgent carried both the old and the new
contract on consecutive lines: "a local/null connectionId falls through
to the profile path verbatim", immediately contradicted by "only a null
connectionId falls through, explicit local is a registry identity".
Dropped the stale line.

Same wording above prepareGatewayForAgent in gateway.ts, tightened to
match what the code actually does: registryBackendScopeKey only collapses
to the bare profile key for a null or empty id, so an explicit local id
scopes to conn:local::<profile> and stays on the registry route.

Comments only, no behavior change. tsc --noEmit, eslint and the three
affected suites (43 passed) re-verified.

Refs #82140
2026-08-18 15:04:28 -07:00
Jack Lau 4e520f0850 fix(desktop): guard the profile publication on its activation result too
The agent path already declined to publish when applyActive() rejected its
activation, but the profile path discarded the same boolean and published
unconditionally. applyActive() returns false when its captured epoch has
been superseded, which happens whenever a newer switch or a teardown lands
while this preparation is still awaiting its route or socket.

The result was not a torn publication. batch() makes those writes
observer-atomic either way. It was something subtler: ONE complete,
internally inconsistent tuple, the CURRENT gateway paired with the stale
target's profile pointer and descriptor. Atomicity cannot make a rejected
activation correct, so the caller has to decline to publish at all.

prepareGatewayForProfile now returns Promise<() => boolean> like its agent
counterpart. The primary and shared-primary thunks return applyActive()
directly; the secondary thunk reports whether the prepared entry was still
current AND the epoch was accepted, keeping the descriptor publish
conditional on having a cached connection so an accepted activation with no
descriptor still moves the companions.

prepareGatewayForAgent's genuinely-local fallthrough now returns the profile
thunk unchanged instead of wrapping it to return an unconditional true,
which had been reporting a rejected activation to the agent caller as a
successful one.

Two regressions on the profile door: a superseded activation leaves all
three stores on the existing complete route with no subscriber notified at
all, and an accepted one still publishes, so a thunk that always reported
false could not pass. The mock thunks in profile.test.ts now return true,
since a bare vi.fn() returns undefined and would read as "superseded".
2026-08-18 15:04:28 -07:00
Jack Lau 162aa6d72a refactor(desktop): wrap the prepareGatewayForAgent signature at the project width 2026-08-18 15:04:28 -07:00
Jack Lau 053eb7aab0 fix(desktop): publish a gateway switch in one nanostores batch
The prepare/publish seam removed the *await* between activating the
gateway and setting the profile pointer and connection descriptor, but
not the *notification* gap. Nanostores drains a store's listeners
synchronously inside .set(), so three sequential sets still let a
$gateway listener run while $activeGatewayProfile and $connection named
the previous backend. That is the same mixed state the seam exists to
prevent, just narrowed from an async window to a synchronous one, and it
is worse to debug because it is invisible in an await-shaped reading of
the code.

batch() defers every notification to the end of the callback, so the
three become one observable transition on both the profile path and the
agent path.

Pinned with a test that attaches a real $gateway listener and asserts the
companions are already current in the first callback; the mock thunks now
publish distinct gateway identities so an out-of-order publication cannot
pass unnoticed, and three existing tests assert $gateway is still the
ORIGINAL object (by identity) on every path that must publish nothing.
2026-08-18 15:04:28 -07:00
Jack Lau 20ccf88acd fix(desktop): fail the agent switch closed when its descriptor lookup rejects
Review caught that the agent path fixed the pending-descriptor race but not
the failure path. `resolveConnectionForActiveAgent` caught a
`getConnectionFor` rejection and returned null, so `Promise.all` resolved as
`[null, activate]` and the switch published anyway: the activation thunk ran
and `$activeGatewayProfile` advanced, while only `setConnection` was skipped.

That is the same mixed state this PR exists to remove, except it does not
close on its own. The pending-descriptor window ends when the descriptor
arrives; a failed lookup never arrives, so `$gateway` named the new backend
while `$connection` described the old one until an unrelated reconnect or
switch happened to repair it. Anything branching on connection mode in
between (plugins, `MEDIA:`, `/api/fs/*`, `/api/media`, image attach) saw the
pair disagree.

Let the rejection propagate, matching `resolveConnectionForProfile`, whose
contract is already exactly this: null means "no desktop bridge" and nothing
else, and a bridge rejection aborts the whole switch before anything is
published. Both doors now fail closed identically, and the caller can retry.

The existing "leaves the prior connection intact when the descriptor fetch
fails" test asserted the old best-effort behaviour, so it pinned the defect
rather than a contract worth keeping. Replaced with a rejected-descriptor
test that asserts none of the three atoms moved and the activation thunk was
never called. The pending-descriptor case keeps its own separate test, so the
success and failure contracts are pinned independently.

Also reworded the publication comments: these are sequential atom writes with
no asynchronous gap between them, not a transaction, and describing them as
one "frame" overstated the guarantee.
2026-08-18 15:04:28 -07:00
Jack Lau d0e0951cf1 fix(desktop): publish the agent activation atomically too
`ensureGatewayAgent` is the (connectionId, profile) door the SDK's `ensureAgent`
goes through, and it landed on main after the profile path was made atomic. It
published in the order the profile path used to:

    await ensureGatewayForAgent(connection, target)   // $gateway flips here
    $activeGatewayProfile.set(target)
    await syncConnectionToActiveAgent(connection, target)   // $connection here

The trailing await is the same mixed-state window: $gateway and
$activeGatewayProfile already name the agent's backend while $connection still
describes the previous one, so any request or plugin mode-listener firing in
that window announces the wrong mode to the new backend.

Both doors now share one seam:

* `prepareGatewayForAgent` mirrors `prepareGatewayForProfile`: dial the socket,
  publish nothing, return the synchronous activation thunk. A local/null
  connection falls through to the profile seam, so the two paths cannot drift.
  `ensureGatewayForAgent` becomes `(await prepareGatewayForAgent(...))()`,
  exactly how `ensureGatewayForProfile` relates to its own prepare.
* `syncConnectionToActiveAgent` splits into `resolveConnectionForActiveAgent`,
  which resolves only. `ensureGatewayAgent` resolves the descriptor and dials
  the socket concurrently, then activates, moves the profile pointer and sets
  the descriptor with no awaits between them.

The best-effort contract on this path is unchanged on purpose: a descriptor
lookup that fails still leaves the previous `$connection` in place rather than
aborting the switch, which is what the profile path does instead. That
difference is deliberate and called out for review rather than quietly
harmonised.

Tests: `profile-agent-activation.test.ts` gains
`never publishes the agent gateway before its connection descriptor`, the mirror
of the profile-path test, asserting a pending `getConnectionFor` leaves all
three atoms on the old backend and that they flip together once it resolves. The
existing mutex and resync tests move onto the prepare/publish mocks, which also
repairs them: that file mocked `@/store/gateway` without `prepareGatewayForProfile`,
so its profile-path cases called an undefined mock after the rebase.
2026-08-18 15:04:28 -07:00
Jack Lau d57f94a330 fix(desktop): publish gateway, profile, and connection descriptor atomically on a profile switch
ensureGatewayProfile used to activate the target gateway and set
$activeGatewayProfile while the connection descriptor fetch was still
in flight, so during that window $gateway already targeted the new
backend while $connection still described the previous one, and any
request or plugin mode-listener firing then announced the wrong mode to
the new backend. A failed descriptor fetch made the mismatch permanent.

prepareGatewayForProfile (new gateway-store seam) opens the socket and
returns a synchronous activation thunk without publishing anything;
ensureGatewayForProfile now delegates to it. The switch resolves the
descriptor and opens the socket first, then flips the active gateway,
the profile atom, and $connection in one synchronous frame. A
descriptor failure aborts the switch as a unit: nothing is published and
every atom still consistently describes the previous profile.

The deferred-descriptor test holds the fetch open and asserts the public
atoms never disagree, then releases it and asserts all three flipped
together; the failure test asserts no partial publication.
2026-08-18 15:04:28 -07:00
hermes-seaeye[bot] 19591aa390 fmt(js): npm run fix on merge (#89501)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 21:51:44 +00:00
seref 359e09fd65 feat(desktop): one-click plugin install via hermes:// deeplinks
Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.

Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
2026-08-18 14:45:23 -07:00
hermes-seaeye[bot] 72f9e01497 fmt(js): npm run fix on merge (#89492)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 21:38:17 +00:00
ethernet ae162f7e5c fix(desktop): clear the theme preview at palette close start
The revert to the committed theme lagged behind Escape. The palette
body stays mounted through the whole exit animation, and the preview
was cleared at unmount. So the repaint waited for the fade.

Subscribe to the palette open store in the body and clear the preview
the moment the store flips to closed. The unmount clear stays as the
backstop for a body that dies without a close.
2026-08-18 17:31:47 -04:00
ethernet 1e3106197b fix(desktop): read the palette highlight from the cmdk store
The highlight preview did not fire. cmdk calls the root onValueChange
only in controlled mode, when the value prop is set. The palette is
uncontrolled, so the preview callback never ran.

Add a HighlightWatcher child that subscribes to the cmdk store with
useCommandState. The store reports the highlight in both modes. The
watcher replaces the dead root prop.

The new test renders a real uncontrolled cmdk root. It proves that
the watcher fires and that the root prop stays silent. If cmdk later
fires the prop in uncontrolled mode, the second assertion fails, and
the watcher becomes removable.
2026-08-18 17:31:47 -04:00
ethernet 40fccbf082 feat(desktop): live-preview themes from the palette highlight
The theme rows in the Cmd-K picker applied a theme only on select.
Now the highlighted row paints its theme immediately.

cmdk reports the highlighted row through onValueChange on the root.
A new optional onHighlight callback on PaletteItem receives it. The
theme rows preview through a new previewTheme function on the theme
context. The preview is not persisted. A highlight on a row without
onHighlight, a page change, a palette close, or a commit clears the
preview. Then the committed appearance returns.
2026-08-18 17:31:47 -04:00
Teknium 052fe7240a fix(providers): plugin-profile fallback skips endpoint-less placeholder profiles
CI slice 11 caught a regression from the salvaged get_provider() fallback:
the "custom" placeholder profile (aliases ollama/local/vllm, empty
base_url) now resolved as a bare ProviderDef before
resolve_provider_full() reached its custom_providers step, collapsing
keyed IDs like custom:local-127.0.0.1:11434 to an endpoint-less "custom"
(test_keyed_custom_provider_bare_custom_fallback_uses_stable_key).

Gate the fallback on the profile carrying a concrete base_url —
placeholder profiles completed by config.yaml keep flowing to the
custom-provider resolution path.
2026-08-18 14:27:36 -07:00
Teknium c7d0f6c35f fix(providers): honor a custom base_url over models_url in fetch_models
Follow-up to the salvaged CommandCode signature fix: accepting base_url
but ignoring it left custom endpoints (user-configured model.base_url /
COMMANDCODE_BASE_URL proxies) fetching the public catalog instead of the
configured one. Reviewer dansigma flagged this on PR #88851.

Class-wide fix, not a CommandCode patch:

- providers/base.py: a caller base_url that DIFFERS from the profile's
  default now wins over models_url. Equality with the default means "not
  customised" (callers pass base_url unconditionally, defaulting to the
  profile's own URL) and keeps models_url as the endpoint, preserving the
  OpenRouter-style split-catalog behavior.
- commandcode: _fetch_commandcode_models() takes the endpoint override;
  both profile overrides forward base_url.
- Tests: base-class precedence (custom beats models_url, default does
  not), CommandCode redirect via live local HTTP server incl. claude-*
  filter, and default-echo hitting the canonical endpoint. All verified
  to fail against the pre-fix implementation (sabotage run).
2026-08-18 14:27:36 -07:00
greyvito 7072fc4f87 fix(providers): resolve plugin-registered provider profiles in get_provider
Plugin-only providers (commandcode, tencent-tokenhub, ...) are absent from
models.dev and HERMES_OVERLAYS, so resolve_provider_full returned None and
/model switches failed with "Unknown provider ..." even though the picker
lists them (CANONICAL_PROVIDERS auto-extends from the same registry).

Fall back to providers.get_provider_profile() before giving up, mapping the
profile api_mode to the ProviderDef transport.
2026-08-18 14:27:36 -07:00
greyvito f5ea3fa9cb fix(commandcode): accept base_url kwarg in fetch_models overrides
The model picker's generic live-fetch path (hermes_cli/models.py
provider_model_ids) calls profile.fetch_models(api_key=..., base_url=...).
Both CommandCode overrides only accepted api_key/timeout, so every picker
open raised TypeError, which was silently swallowed, leaving the provider
with zero models.

Match the base ProviderProfile.fetch_models signature (base_url kwarg) and
add a regression test asserting both profiles accept it.
2026-08-18 14:27:36 -07:00
Teknium d354af5e12 feat(desktop): unified Sessions list shows every connected gateway's chats (#88880)
The global SESSIONS sidebar only aggregated local profiles and v1 per-profile
remote overrides. Sessions living on v2 registry connections (remote/cloud/ssh
gateways) never appeared — the remote API returned the rows, but the renderer's
Sessions component received an empty array (#88880).

- electron/profile-session-routing.ts: fetchRegistrySessionRows reads each
  CONNECTED registry gateway's session list (ssh backends natively, shared
  remote/cloud hosts via one cross-profile aggregate with a legacy flat-list
  fallback), tagging rows with connection_id + owning profile.
  spliceRegistrySessionRows dedupes them into the unified list and extends
  per-profile totals. Reads never pass include_hidden, so Bot Mode's hidden
  canonical chats stay OUT of the global list, same as local sessions.
- electron/main.ts: mergeRemoteProfileSessions splices registry rows; the
  /api/profiles/sessions[+/sidebar] intercepts also fire when registry
  gateways are pooled (previously only v1 remote overrides). Only
  already-pooled backends are read — a sidebar refresh never dials or spawns
  a backend (the roster-respawn trap), and a dead gateway contributes nothing.
- types/hermes.ts + use-session-actions: SessionInfo carries connection_id;
  resuming a registry-owned row activates its connection-scoped gateway
  (ensureGatewayAgent) instead of a same-named local profile.
- store/gateway.ts: ensureActiveGatewayOpen rides out an in-flight secondary
  activation (bounded 8s) instead of failing instantly — the Sessions "+"
  during remote wake no longer errors "Hermes gateway is not connected".

Tests: 5 new registry-source/splice unit tests (tagging, shared-host
aggregate + legacy fallback, dead-gateway isolation, hidden-flag contract,
dedupe/totals) and a sabotage-verified activation-wait regression test.
2026-08-18 14:27:15 -07:00
Teknium aae96913df fix(desktop): register plugin notify handlers only after guards pass; re-resolve activate at the IPC boundary
Two hardening follow-ups on the salvaged #84192 work:

- dispatchNativeNotification now reports whether the notification actually
  reached the OS bridge, and dispatchPluginNativeNotification registers its
  onActivate/onAction closures only on true. Previously a throttled,
  disabled, or baseline-suppressed notification registered handlers that no
  click could ever clear, leaking them for the window's lifetime.
- The renderer's onNotificationActivate handler re-resolves the activate
  payload through resolveHermesOpenPath instead of trusting the pre-IPC
  validation, keeping path validation in one funnel for any future
  hermesDesktop.notify caller.

Adds a regression test covering the throttled and suppressed cases.
2026-08-18 14:26:28 -07:00
seref 73ddf6665c feat(desktop): rich plugin OS notifications with deeplink activation
Extends ctx.os.notify (the curated plugin OS door from #78685) with icon,
action buttons, and a serializable `activate` target. Body/action clicks
focus the window and navigate to the plugin's screen; activation paths
share one resolver (hermes-open-target.ts) with hermes:// OS deep links,
so `hermes://index-network/intent/1`, `/index-network/intent/1`, and
{ path, params } all land on the same hash-router route. Approval
notifications keep their existing session-scoped channel.

Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied
onto current main; branch carried merge commits so a single authored
commit preserves attribution).
2026-08-18 14:26:28 -07:00
Teknium 72b7c6c8d1 fix(models): keep discovery sentinels out of the user-facing models mapping
PR #67934 marked auto-discovered catalogs by writing two sentinel keys
INSIDE the user-facing ``models`` mapping of custom provider entries:
``__discovered_model_catalog__`` (written by
_save_discovered_models_to_config) and ``__explicit_model_allowlist__``
(injected by _normalize_custom_provider_entry). Every consumer of that
mapping — pickers, selectors, gateway/agent readers, and the user's own
config.yaml — had to know to filter those keys, and any site that
didn't listed them as phantom model IDs (``__discovered_model_catalog__``
showing up as a selectable "model"). The v11→v12 config migration and
the ACP session-state test caught exactly that leak on main.

Replace the in-mapping sentinels with a single entry-level flag:

- ``models_discovered: true`` now sits next to ``models``/``base_url``
  on the provider entry; the models mapping stays a clean
  ``{model_id: metadata}`` dict with no reserved keys.
- _save_discovered_models_to_config writes the new shape and refreshes
  catalogs it previously discovered (entry-level flag or legacy
  sentinel) instead of treating them as user-curated metadata.
- _normalize_custom_provider_entry no longer injects
  ``__explicit_model_allowlist__``; a dict-shaped models mapping counts
  as an explicit allowlist exactly when the entry is NOT marked
  models_discovered.
- _models_config_is_allowlist takes the discovered flag as a parameter
  (new helper _entry_models_discovered resolves it, including the
  legacy in-mapping sentinel); all call sites updated
  (model_switch.py, model_setup_flows.py, acp_adapter/server.py).
- Backward compat, no config version bump: configs written by a
  pre-fix Hermes (sentinels inside models) still read correctly —
  ``__discovered_model_catalog__: true`` is treated as
  models_discovered, both sentinel keys are stripped from model
  listings, and the next discovery save migrates the entry to the
  clean shape. Covered by a new regression test.

Also restore ``except Exception:`` on the pre-existing guards this PR
had narrowed to specific exception tuples (the resolve_runtime_provider
fallback in switch_model, the picker discovery/cache guards in
list_authenticated_providers, _get_model_config_dict, and
_credential_fingerprint). Those guards were intentionally broad on
main — a failed resolution or probe must degrade to the fallback path,
never crash the model switch. Guards the PR introduced for its own new
probe code keep their authored tuples.

The ACP new_session payload also goes back to
probe_current_custom_provider=False, matching the contract main's
test_new_session_returns_authenticated_cross_provider_model_state pins
(session opens must not block on live-probing the current custom
endpoint).
2026-08-18 14:26:16 -07:00
Vadelma 015f9990c6 fix(models): persist named custom catalog identity
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 4daaf1e619 fix(acp): preserve colon-bearing custom provider prefixes
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 7fb6b28ec8 fix(models): complete selector parity safeguards
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 81e813507f fix(models): preserve empty catalog and custom model semantics
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 9d2ba6c655 fix(acp): isolate custom catalog identities
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 7e61bd38c6 fix(models): preserve discovery provenance and endpoint identity
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma f70e9abce9 fix(models): close final provider discovery edge cases
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 17405de9c1 feat(acp): preserve native provider catalogs and identities
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma fa1bb88e3e feat(models): propagate native discovery across selectors
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
Vadelma 638b72f5ef feat(models): add native Ollama catalog semantics
Co-authored-by: Taneli Mielikäinen <taneli.mielikainen@iki.fi>
2026-08-18 14:26:16 -07:00
hermes-seaeye[bot] 6a1fb37c94 fmt(js): npm run fix on merge (#89485)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-18 21:26:10 +00:00
Teknium 38b7a40382 chore: map whisky0809 contributor email 2026-08-18 14:19:39 -07:00
whisky0809 edb9f96d6d fix(desktop): keep escaped dollars from ending a shielded math span
The inline branch of MATH_SPAN_SPLIT_RE excluded `$` from the body outright,
so a `\$` inside inline math — a literal dollar sign, valid TeX — broke the
span match and the shield silently didn't apply. `$\sqrt[3]{8} + \$5$` still
lost its index.

Step over escape pairs instead, matching the escaped-delimiter rule
findClosingSingleDollar already applies via isEscapedAt. The two body
alternatives are disjoint on their first character, so the added quantifier
can't backtrack ambiguously.

Reported by Copilot in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
2026-08-18 14:19:39 -07:00
whisky0809 21fc7c5a14 fix(desktop): shield math spans from the visible-prose rewrites
`$\sqrt[3]{8}$` renders as a plain square root — the index is gone. It is
not a KaTeX layout problem: the index never reaches KaTeX. CITATION_MARKER_RE
strips `[3]` as a citation marker, because its lookbehind accepts any letter
and the `t` of `\sqrt` qualifies. That runs inside normalizeVisibleProse,
which splits out inline code spans but not math, so TeX is fed to rewrites
written for prose.

Numeric-only, which is why `\sqrt[n]{8}` survives and made this look like a
layout edge case rather than a preprocessing one.

Shield math the same way inline code is already shielded: split each prose
part on math spans and rewrite only the segments between them. That also
takes math out of the reach of the other rewrites in that pass
(autoLinkRawUrls, LOCAL_PREVIEW_URL_RE, the ``` stripper, linkifySessionRefs),
any of which can corrupt TeX the same way with different input.

The split is capturing, and math segments are identified by index parity
rather than a leading `$`, so a prose run that merely opens with a stray
dollar cannot be mistaken for math. Escaped `\$` delimiters stay prose, which
is what keeps `$5 and $10` escaping intact.

Verified in the real renderer through the desktop mock-backend E2E harness:
`.katex .root` (the span KaTeX emits for a radical index) goes from 1 to 4 on
the same four-radical reply, and the MathML annotations show KaTeX receiving
`\sqrt[3]{8}` intact rather than `\sqrt{8}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
2026-08-18 14:19:39 -07:00
whisky0809 47066f5ea0 fix(desktop): normalize hugging multi-line display-math delimiters
Multi-line display math whose $$ delimiters hug the body
(e.g. $$\begin{aligned}...\end{aligned}$$) renders as raw error text.
remark-math's flow-math construct is fence-shaped: text after the
opening $$ on the same line is read as an info string and discarded,
and the closing $$ is only recognized alone on its own line. So the
block never closes and KaTeX paints the remains via its error fallback.

splitHuggingDisplayMath moves those delimiters onto their own lines. It
runs AFTER normalizeMathDelimiters because that rewrite is itself a
source of the hugging form: a multi-line \[...\] comes out of it as
$$\begin{aligned}...\end{aligned}$$, so the same bug reached users who
never typed a $$ at all.

Single-line $$...$$ is left alone (it routes through the inline
math-text construct and already renders), container prefixes are
replayed onto the delimiter lines, and both patterns anchor $$ to the
start of the line, which keeps them from firing inside an inline code
span.

Verified end to end: the repro emits katex-error through
remark-math + rehype-katex before this change and not after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:19:39 -07:00