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.
test_contract_doc_conformance enforces that every CapabilityDescriptor
field appears in docs/relay-connector-contract.md §2 so connector authors
mirror the full surface — caught in CI (slice 2/12); the descriptor gained
supports_inchannel_continuable and supports_block_formatting without the
doc rows.
Preserve current upstream Desktop and Bot Mode behavior while carrying the
connection-bound remote Bot routing implementation forward without rewriting
the signed feature commit.
Field report (enterprise side-by-side, 2026-08-18, finding 2): identical
agent output renders native rich_text lists, Block Kit tables, and
highlighted code on native Slack, but literal '-' bullets and code-fence
tables on the relay lane. Native reads platforms.slack.extra.rich_blocks /
markdown_blocks and renders Block Kit locally; relay frames carried no
formatting signal, so the connector had no way to know the operator wants
block rendering.
Contract (additive, v1): the connector advertises supports_block_formatting
in its capability descriptor. When it does AND the operator enables
platforms.relay.extra.slack.rich_blocks / markdown_blocks (same per-platform
sub-block and same _coerce_flag semantics as the other relay Slack knobs),
the gateway stamps format_hints into outbound metadata on BOTH text egress
lanes — send and edit (a streamed reply's final edit carries the finished
markdown, so it must signal too or streams seal as plain text). The
connector renders blocks and keeps plain text as the fallback.
Old connector: never advertises -> no dead metadata ever sent. Old gateway:
never stamps -> connector renders plain text as today. Knobs default OFF,
matching native's opt-in posture.
8 new tests: descriptor default/from_json, hint stamping (capable+enabled),
capability-absent suppression, knobs-off suppression, YAML-quoted-false
coercion, partial knobs, edit-lane parity.
Field report (enterprise side-by-side, 2026-08-18, finding 1 — the relay-only
blocker): on relay-fronted Slack, cron briefs always deliver into a dedicated
thread; the flat continuable surface (cron_continuable_surface: in_channel)
that native Slack supports is inert, so plain DM replies never continue the
job and the main conversation never sees the brief.
Three gaps closed:
- CapabilityDescriptor gains supports_inchannel_continuable (default False,
additive within contract_version 1; from_json ignores it from old
connectors, old gateways filter it as unknown). The connector advertises
it per platform at handshake.
- RelayAdapter maps the bit onto the adapter capability surface in both the
constructor and _apply_descriptor (renegotiation), so the scheduler's D6
fail-safe gate sees it exactly like native Slack's class attribute.
- _resolve_cron_surface_mode replaces the scheduler's inline flat-key read:
native keeps the shipped flat shape; the relay lane reads the same
per-logical-platform sub-block as the documented relay Slack knobs
(platforms.relay.extra.slack.cron_continuable_surface), sub-block wins,
scoped so a slack block cannot leak onto other fronted platforms.
The seed path needs no changes: RelayAdapter inherits set_session_store
(wired by the generic adapter boot loop) and _seed_cron_channel_session
keys the flat session off the logical platform_name.
12 new tests: descriptor default/from_json/legacy-absence, adapter mapping
constructor + renegotiation, and the surface-knob matrix (native flat key,
relay sub-block, per-platform scoping, precedence, defaults).
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.
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)
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.
- plugins/image_gen/openrouter: list_models() now queries the endpoint's
/models catalog filtered to output_modalities containing "image"
(per-backend 5-min cache, 10s timeout, static 2-model chain as offline
fallback; openrouter/auto* router pseudo-models excluded). Every image
model OpenRouter serves — including future releases — is selectable in
`hermes tools` with no code change. Applies to Nous Portal too via the
shared provider class.
- plugins/image_gen/xai: forward the dispatched model kwarg into
_resolve_edit_model() so an explicitly selected edit-capable model is
honored on /images/edits (extends the salvaged #55893 fix to the edit
path; text-only models still fall back to quality).
- Tests: OpenRouter live-catalog filtering/exclusions/order, offline
fallback, cache single-fetch; xAI edit-kwarg forwarding incl. the
text-only-hijack negative case.
Live-verified against openrouter.ai: 9 image-output models returned and
rendered, matching the public models?output_modalities=image listing.
Adapter ingress derives a session key BEFORE the runner stamps
source.profile in _make_profile_message_handler, so the namespace fell
back to the active profile and every bot in a multiplexed gateway
produced agent:main:<platform>:<chat>. A Telegram private chat reports
the user's own id as chat.id, identical for every bot, so two profiles
sharing one human collapsed onto a single lane: _pending_text_batches,
_active_sessions, the busy-session guard and _post_delivery_callbacks are
all keyed on that string. A day of production logs across two bots shows
60 flushes, none carrying the secondary profile's namespace.
set_owner_profile records credential ownership on the adapter and
_session_key_profile resolves the namespace as source.profile ->
_owner_profile -> the session store's resolver, so a secondary adapter
keys into its own namespace even before the source is stamped. Stamped
sources keep priority, so relay/connector ingress, which routes per event
rather than per credential, is unchanged. _configure_profile_adapter
installs the owner alongside the other handlers, covering startup and
reconnect.
Every candidate is type-checked as a non-blank str, and every attribute
read goes through getattr: adapters are routinely built without
BasePlatformAdapter.__init__, and a duck-typed session store returns a
truthy non-string that would otherwise be interpolated into the key as
agent:<MagicMock ...>:.
Also routes the four call sites that passed no profile at all (feishu
media batches, raft, slack _session_key_for_source, telegram photo
batches) through the same resolver.
test_multiplex_busy_input_mode's secondary-adapter busy case seeded
_active_sessions with the unstamped agent:main: key, asserting the
pre-fix collapse. It now seeds the lane the profile-owned adapter
actually derives.
A primary adapter has no owner and an unstamped source, so it resolves
exactly as before; with multiplex_profiles off the resolver returns None
and every key is byte-identical to today's.