host.connections() resolves the IPC handler hermes:connections:list, which
returns the registry OBJECT ({version, primary, connections: [...]}) — not a
bare array. CreateAgentDialog did setConnections(Array.isArray(value) ? value
: []), so on a multi-connection desktop the picker gate
(Array.isArray(connections) && connections.length > 1) never fired and the
'Create on' picker stayed hidden, making cross-machine bot creation
impossible despite the multi-connection feature being documented.
Any new agent is still created on the active gateway (unchanged behaviour);
the picker is the only path that regressed. The built-in Connections UI
(refreshConnectionsRegistry) consumes the same registry object, so the IPC
handler contract is left untouched.
Adds a regression test asserting the unwrap and that the IPC contract is
preserved. Plugin suite: 309/309 pass.
Preserve the connection-bound Bot and session routing implementation while
adopting upstream's modular Desktop API split and all changes through
b2057c168.
The wordmark and tagline on an empty chat had no off switch. Add an
Appearance row, Intro Splash, that hides it. The setting is on by
default, so the current experience does not change.
The splash is renderer chrome and no other Hermes surface can change
it, so the state stays local (localStorage) like the Chat Backdrop
toggle beside it. It does not mirror into gateway config.
Move the visibility condition out of the chat god-component into
shouldShowIntro(), next to the isRouteSessionMismatch() helper. The
tests prove that the toggle outranks every window and session clause:
off is off.
The ship button always targeted `main`, so a tile Review still prompted
the workspace session. Remember the originating composer target with the
pane's cwd, capture the live surface at click, and toast if that chat
isn't on screen instead of dropping the click.
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
Review "Ask Hermes to open PR" was a window-level event that every mounted
composer claimed with `target === 'main'`, so one click shipped every open
session and project with dirty files. Bind the request to the visible
surface captured at click time.
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
The same AST sweep over specs found fixtures maintained in parallel
across suites that have no reason to know about each other.
Twenty-one specs each mounted useMessageStream themselves and ten of the
harnesses were byte-identical; twenty now take renderMessageStream, with
overrides for the seams that genuinely vary. The SessionInfo builder was
spelled out field-by-field in seven specs, so a new backend field broke
seven files instead of one. Twenty specs carried their own inert
ResizeObserver and eleven repeated the animation-frame, CSS.escape,
scrollTo and WAAPI stubs the transcript needs to mount at all — split by
scope into src/test/jsdom for what any component might need and the
assistant-ui folder's own kit for the transcript. Plus the window-state
bridge, deferred, the external-store thread runtime, the manual
createRoot harness, and the per-folder caret, env-var, provider and
session fixtures.
Left alone on purpose: the store suites' makePrimary, where the vi.mock
harness around it is the actual duplication and cannot be hoisted out of
a hoisted factory; electron's deferred, where reaching into src/ from
the main process would invert the layering for eight lines; and the two
suites that compose another hook alongside the stream.
Deciding whether the OS can back glass needs os.release(), but every
Hermes window runs its preload with sandbox: true, where require is a
polyfill limited to electron, events, timers and url. The node:os import
threw before contextBridge ran, so window.hermesDesktop was never defined
and the app booted straight into "Desktop IPC bridge is unavailable".
Main already computes both verdicts, so preload asks for them over a
synchronous channel instead. No reply degrades to no glass, which is an
ordinary opaque window rather than a page thinned over nothing.
Splitting the god files made a pile of copy-paste helpers visible and,
for the first time, fixable — sharing them previously meant importing a
god file. Hashing function bodies through the TypeScript AST found
twelve groups desktop-wide; production code is now at zero duplicates.
Each helper went to the module that already owns its concern:
firstStringField to lib/text, the two REST 404 predicates to
lib/gateway-rpc beside isMissingRpcMethod, useDebounced and
prefersReducedMotion to their hooks, the superseded-bootstrap guard to
electron/ssh-connection, the composer keyup handler to the trigger hook
that owns the rest of that state machine, and clampDataUrlReadMaxMb to
apps/shared, replacing a "keep these in sync" comment between two
copies.
Only helpers with no existing owner got a new file: lib/mcp-servers,
lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command
palette's status row. Error-shape predicates are the worst thing to
copy — when the backend changes how it reports a missing route, every
copy has to be found.
The row offered one unlabelled 0-100 slider whose meaning changed with the
mode. Under Clear it is window opacity; under Glass it was never opacity at
all — it sets how much of the theme tint stays painted over the material.
Same track, same percent readout, two different things.
Glass now gets a labelled panel: Tint keeps the renderer lever, Fade is a
real native opacity on the ramp Clear uses, defaulting to 0 because fading
a glass window fades its text — the thing Glass exists to avoid. Frost
offers only the rungs the OS renders distinctly, so Windows shows three
instead of two buttons that composite identically; a frost saved on a Mac
highlights the button that renders the same backdrop rather than leaving
the picker blank, and is not rewritten.
Linux loses the row entirely, from the page and from settings search.
setOpacity is a documented no-op there and there is no material, so both
halves were dead — a lever that moved a number and changed nothing.
52 handlers move into five registrars — git, pet overlay, hud, fs and
terminal. Each takes injected deps (window handles, binary resolvers,
path hardening) following the existing electron/ module pattern rather
than closing over main.ts locals, and terminal-ipc returns its dispose
helpers so SSH teardown and app shutdown keep working.
Types, part builders, tool parts, hydration and reconciliation, behind a
barrel that keeps the @/lib/chat-messages path.
The folder was added without removing chat-messages.ts, so resolution
preferred the file and all its importers kept hitting the monolith while
the new modules sat dead. Deleting it surfaced a missing preset field on
GatewayEventPayload that layout.apply needs, hidden until the folder
actually resolved, and a completeOpenStreamParts helper copied into two
modules when only one calls it.
The monolithic if/else-if dispatcher becomes nine modules by event
family. The routing preamble runs once, then each handler consumes its
own types and reports whether it did, so dispatch stops at the first
taker. Families are mutually exclusive by type, so ordering between them
is inert; ordering within a family is unchanged.
Restores two things the extraction dropped against a moving base: the
layout.apply handler, and the multi-question clarify.request path. A
batch clarify was consumed and never parked, so the agent blocked on
clarify.respond with no card rendered — the existing tests passed
because they assert "exactly one clarify card", which is also true when
the request is dropped and only the tool.start row exists.
Grouping → Profile persists ALL even with a single profile. Recents
filtered that pool against the __all__ sentinel and emptied the list.
Cron and messaging already used filterSessionsByProfileScope; recents
now does too.
Co-authored-by: andyst-dev <150129844+andyst-dev@users.noreply.github.com>
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.
Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.
Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
2,248 lines of gateway REST client become twelve modules by domain, with
hermes.ts left as a barrel so all 144 importers stay put. The import
graph is a star — every domain module imports only ./client, and client
imports nothing back — so there are no cycles.
The barrel names client's public exports rather than re-exporting it
wholesale. Splitting a module forces its private helpers into exports so
siblings can reach them, and export * would then republish them:
profileScoped, connectionScoped and capabilityScoped were private to
hermes.ts and have to stay that way, or a call site can assemble its own
request scope and drift from the api layer.
Unscoped getSession hits the primary backend. A 404 then skipped the
active profile in the remaining probes, so chats on a non-default
profile never loaded.
Co-authored-by: Michael McAllister <michael@empowerlo.com>
The Desktop renderer crashes with `RangeError: Invalid array length` thrown
from `Array.push` inside nanostores' `notify()`. The shared `listenerQueue`
grows without bound because `reconcile()` is subscribed to BOTH `$sessions`
and `$pinnedSessionIds`, and `pullRemotePins()` mutates `$pinnedSessionIds`
(via `pinSession`/`unpinSession`), which fires `reconcile()` again
synchronously.
The existing `mirrored`/`pending`/`unconfirmed` fences only cover a *bounded*
single-toggle echo. They do not cover the *unbounded* oscillation that occurs
when two profiles share a session id with conflicting `pinned` flags (copied or
imported profile databases). A profile-blind pull then pins and unpins the same
durable id in one pass, re-firing `reconcile` forever until the queue overflows
and the renderer dies.
Two changes:
1. `rowsByPinId()` collapses the cross-profile session list to one
authoritative row per durable pin id, preferring the active gateway's
profile (the same tie-break `resolveLoadedRow` uses). `pullRemotePins()`
iterates the deduped rows, so a conflicting duplicate can no longer pin then
unpin the same id in a single pass.
2. A re-entrancy guard on `reconcile()` so a synchronous re-entry (from the
`$pinnedSessionIds` listener firing during `pullRemotePins`) returns
immediately instead of recursing.
Also fixes a latent TDZ `ReferenceError` in `session-unread.ts`: `isPlainRecord`
was declared after its first use through `persistentAtom`, so decoding a
persisted value could throw `Cannot access 'isPlainRecord' before
initialization`.
Regression tests cover the duplicate-id oscillation and the active-profile
tie-break.
Preserve current upstream Desktop and Bot Mode behavior while carrying the
connection-bound remote Bot routing implementation forward without rewriting
the signed feature commit.
AlertTitle clamps to one line, so Desktop error toasts hide the rest of the message behind an ellipsis. Override that clamp, let the title wrap, and cap height so a huge error scrolls instead of covering the chat.
Carry immutable connection and profile ownership through Bot Mode actions,
session hydration, transcript loading, new chats, and profile deletion.
Keep same-named local and remote profiles isolated, preserve the full context
menu, and migrate Bot metadata to connection-qualified storage.
Add adversarial routing, alias, migration, cache, and compatibility coverage.
resolveStoredSession never probed the ACTIVE profile: the unscoped
/api/sessions GET routes to the PRIMARY backend (not the active
gateway's), and the cross-profile probe loop explicitly skipped the
active key. A hidden Bot Mode canonical chat — never present in the
sidebar cache — owned by the focused bot therefore resolved to
undefined on every switch. The transcript prefetch then went unscoped
to the primary backend, 404'd, and the thread painted empty until the
user opened the session explicitly via right-click → Sessions (which
seeds the cache with a profile-stamped row).
Probe the active profile first in the by-id ladder, so hidden and
uncached sessions on the focused profile resolve with ownership and the
prefetch routes to the owning backend.
Live-repro'd headless via CDP with 3 bot profiles mid-turn: before the
fix every focus-switch painted a blank thread ('Waking up <bot>…');
after, the full transcript paints. Reported by @tbkbossswaglord.
Three fixes for the "Install on this agent" pipeline, covering the whole
split-brain class between action-spawning endpoints and their status polls:
1. electron/connection-config.ts — the /api/actions/{name}/status poll family
now routes to the same backend as every action-spawning route. Before,
POST /api/skills/hub/install ran on the PRIMARY backend (scoped route)
while the follow-up status poll for a non-default profile routed to the
profile's POOLED backend, which never registered the dynamic action name
(skills-install-<slug>-<hash> lives only in the spawning process's
memory) -> 404 "Unknown action" toast even though the install succeeded.
POST /api/mcp/catalog/install joins the scoped table for the same reason.
2. src/store/hub-actions.ts — a non-zero subprocess exit now rejects with the
action log tail so the caller's catch toasts it. Before, a failed install
(scan gate, network, bad identifier) stopped silently: no toast, no row
flip, and the unchanged skills list read as "install did nothing".
3. src/contrib/runtime-loader.ts — a disk plugin copy shadowed by a bundled
twin now publishes a visible "(stale disk copy)" inventory row carrying
the folder path, instead of a console.info nobody sees. Stale
desktop-plugins/ leftovers from dev deploys are the same folders that
actively break the feature on shells without the bundled twin.
nanostores 1.4.0-1.4.1 annotate batch() @__NO_SIDE_EFFECTS__. Rollup
(via vite build) honors that and erases a result-unused batch(...) call
as dead code -- callback included. Since d57f94a33/053eb7aab/4e520f085
moved the gateway-switch publication (activate() +
+ ) inside batch(), packaged desktop builds lost the entire
publication: clicking a profile in the rail did nothing at all.
Dev builds and vitest run unminified, so only the packaged app broke.
nanostores 1.4.2 removes the annotation from batch() (it stays on the
creation functions, where it is correct). Bump all three pinned copies
(apps/desktop, apps/bootstrap-installer, ui-tui) and add a regression
test asserting the installed nanostores never re-annotates batch.
require.resolve returns the macOS realpath (/private/var/...) while
os.tmpdir() stays on the /var symlink, so a raw join() deepEqual failed
even though the spawn was correct.
Re-lands the goals of the reverted atomic-publish series (#89483, reverted
in #89785) without the fail-closed decline path that killed profile clicks,
and fixes the underlying pruner race the original series exposed.
Two changes relative to the restored (pre-series) behavior:
- Publication is atomic and mode-safe (#46651): the switch resolves the
target's connection descriptor CONCURRENTLY with the socket work and
publishes $activeGatewayProfile + $connection in one nanostores batch()
frame, so no request or plugin mode-listener observes the new gateway
beside the previous profile's descriptor. Unlike the reverted series, a
failed descriptor lookup fails OPEN — the switch still lands, the previous
descriptor stays, boot/reconnect resyncs it later, and the failure is
logged instead of swallowed.
- The live-work pruner can no longer kill a switch mid-dial (#89622's root
race): ensureGatewayForProfile / ensureGatewayForAgent lease their entry
(activationLeaseUntil, 30s bound) for the duration of the dial and
pruneSecondaryGateways spares leased entries. A switch target is not yet
active, has no live sessions and holds no request lease, so any prune
recompute during a cold pool spawn used to dispose the dialing entry.
Bounded lease: an orphaned one self-expires.
The agent door logs a non-landing activation (target removed mid-dial)
instead of resolving silently, but never fails the switch closed.
Live E2E (Electron over CDP, 3 local profiles, cold + warm): 10 sequential
switches, rapid two-click interleave (last click wins), 6-click stress run —
all land, overlay always clears, fresh pool backends spawn on cold switches.
Tests: gateway-activation-prune-lease suite (mid-dial prune survival on both
doors, lease release, lease expiry); the invalidated-registry-identity pin
updated for the concurrent (read-only) descriptor probe.
The unread check-all was a sibling of the +/filter cluster. The header
is justify-between, so that extra child sat in the middle as a blank
24px hole until hover.
The demo existed to iterate on the tour UI without spending an agent turn per
look. That work is done and merged, so it is scaffolding now — a floating
button, a hotkey, a palette row and a window hook, none of which anyone needs
again.
Removing it restores the credit-notice demo's original single-disposer wiring
and drops the Compass icon export, which the demo was the only consumer of.
The tour feature itself is untouched.
The transcript's activity row went silent for most of an agentic turn. Two
gates were too narrow. It mounted only while the tail bubble's own status was
`running`, so a turn that seals a bubble mid-flight (message.interim) or
finishes one while the agent keeps going left a settled message at the tail and
unmounted the row entirely. And its activity signature was part count plus text
length, which a landing tool result does not change — a result mutates the part
that was already there — so the pause after a call ended was read as more of
the same silence and dated from whenever the call started.
Between them, the app spent whole stretches with the composer's arc border lit
and Stop armed while the thread showed nothing and counted nothing.
The row now follows the same busy signal the composer does, is mounted by the
tail unconditionally, and decides for itself whether the turn owes the user a
line. A named wait (compaction, provider wait, a tool being drafted) says so
immediately; an unnamed gap earns two seconds of quiet first, so a run of quick
calls doesn't strobe. It still defers to the two waits already accounted for
elsewhere: a prompt the user is answering, and a tool call in flight carrying
its own row and timer — anywhere in the message now, not just the last part,
and excluding the silent tools that render nothing to defer to.
Renamed to TurnActivityIndicator / `aui_turn-activity`: it hasn't measured a
stall since it started measuring the whole turn.
`turnStartedAt` was only reachable through the global atom, which mirrors
whichever session is currently viewed — the same trap `$busy` and `$messages`
were moved out of. Anything in a tile that measures a turn was therefore
timing the primary chat's.
Put it on SessionView beside the other per-surface signals: the primary falls
back to the draft atom while a new chat has no slice yet, a tile reads its own.