* fix(desktop): MEDIA:-delivered non-media files route to the preview pipeline — PDFs/data files get the file card instead of a dead 'Open' anchor (extends #84951 to every extension)
* docs(prompt): desktop guidance aligned with any-file MEDIA: delivery — preview card truth, local-markdown-image block warning
- Preserve streamed assistant text in Desktop UI when message.complete delivers empty text.
- Prevent destructive hydration in Desktop useMessageStream over rendered text on empty completion.
- Recover stream buffer in finalize_turn when final_response is empty on healthy turns.
- Unify in-place blank assistant repair, watermark clone resolution, non-blank concurrent winner adoption, and batch row appends into a single atomic guarded SessionDB transaction.
- Synchronize canonical committed content to live in-memory messages dicts and preserve all-or-nothing rollback semantics on persistence failure.
`pumpStreamToFile` opened the user-chosen destination with
`fs.createWriteStream`, which truncates the target the instant it opens,
and its error path then unlinked that same path. When a user picked an
existing file in the Save dialog (and confirmed the overwrite) and the
gateway dropped mid-stream, the original was gone: truncated first,
deleted second, with nothing written in its place. The data-URL
compatibility fallback (`saveGatewayFileViaDataUrl`) had the same class
of bug via `fs.promises.writeFile`, which truncates before the write
completes.
Both paths now go through one failure-atomic primitive. Bytes land in a
short, randomly named sibling temp file (`.hermes-download-<hex>.part`,
same directory so the final step is a same-volume rename), created with
`flags: 'wx'`, and are renamed onto the destination only after the whole
body has been written and the descriptor released. The destination is
never opened before that point, so a failed download leaves whatever was
there untouched.
- Ownership-gated cleanup: the temp file is unlinked only after the
stream's 'open' event proved THIS operation created it. An exclusive
create that fails before open (EEXIST collision, EACCES, missing
parent) never removes a file that belongs to someone else.
- `WriteStream.close(cb)` rather than `end(cb)` before renaming: `end`'s
callback fires on 'finish' while the fd may still be open, and Windows
refuses to rename a file with an open handle. Falls back to `end` for
stream shapes without `close`.
- The failure path waits for 'close' (bounded by a 2s grace period)
before unlinking, for the same reason: `destroy()` releases the fd
asynchronously and an unlink racing the open handle would leak the
`.part` file on Windows.
- A rename failure (destination locked, permissions) removes the owned
temp file and rejects; nothing is left behind.
- Fixed-length temp name so a long user-chosen filename cannot push it
past the filesystem's name limit.
- `fsPumpDeps()` is the single production deps factory (`'wx'` create,
`fs.promises.rename`, `fs.promises.unlink`); `writeBufferToFile()`
routes the data-URL fallback through the same pump. `PumpDeps` gains
`rename` and a `tempPathFor` test seam.
Tests. Fakes: temp-then-rename on success, close-before-rename ordering,
the regression itself (destination neither opened nor unlinked when the
response fails mid-stream), write-error cleanup, close-before-unlink
ordering, rename-failure cleanup, pre-open EEXIST leaves the colliding
file alone, `writeBufferToFile` success and post-open write failure, the
temp-name length bound, and the `main.ts` wiring. Real filesystem
(`gateway-file-download.fs.test.ts`, exact production deps in a scratch
dir): completed download replaces the destination with no temp left;
mid-stream failure leaves the pre-existing destination byte-for-byte
with no `.part`; failure into a fresh name leaves nothing; seeded temp
path survives a pre-open EEXIST with no rename; rename failure (directory
at the destination) cleans the owned temp; data-URL fallback success and
missing-directory failure.
Adds the contributor email mapping required by the attribution check.
Fixes#96597
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015u8q2pHVPZmxpSrgkt94jC
The HUD has no in-app browser, so a click tried to paint a webview
into the transparent overlay (OAuth). Hand those links to the OS,
mount the context menu, and skip preview-tile docking.
Ignore-mouse cannot restore on X11, so a visible band that still has
pointer-events:none swallows clarify options and links. Held prompts
and solid-window bands now take the pointer without composer focus.
The rename and settings dialogs stay mounted while closed, so they render
with a null board on every pass. Their mutation callbacks read `board!.slug`,
and the React Compiler lifts a callback's property reads into its render-time
dependency check — so the read escaped the closure and dereferenced null
immediately on mount, taking the whole contribution down behind its error
boundary.
The non-null assertion never guarded anything; it erases at compile time.
Resolve the slug null-safely in the component body instead, which is also
the form the compiler can hoist safely.
Only the bare-lambda shape is affected: the inline `useMutation({ mutationFn })`
this replaced memoized on the whole `board` object and kept the read inside
the closure, so the regression arrived with the extraction into
`useBoardWrite`, not with the feature.
Per-item menus across the app say "Rename", "Delete", "Export",
"Archive" — the row already names what you are acting on. A handful of
places had drifted to verb+noun or Title Case, so the same action read
differently depending on where you found it.
Sessions and projects now say "Rename…" like profiles and the file tree
already did. The per-profile context menu says "Export…"; the noun stays
on the profiles-list button and the native file-dialog title, which
stand alone. Bots drop "Delete Group" and "Edit Profile" for "Delete"
and "Edit…". Title Case gives way to sentence case in the file menu,
review tree, and model menu.
Nouns are kept wherever they carry weight: dialog titles, icon-button
tooltips, "Remove worktree" (its menu also has a plain "Remove"), and
"Open Bot Chat", which names the canonical session titled exactly that.
The board switcher could create and configure boards but not move,
rename, or remove one. Rename technically existed, buried as a field
inside "Settings…", which is why it read as missing; it now has its own
entry and the settings dialog is left owning scope alone.
Delete archives rather than erases — the board's directory moves to
boards/_archived/ and the toast names the path — and never appears for
`default`, which the backend refuses to remove.
The three dialogs had grown three copies of the same shell, the same
"invalidate the list and close" mutation tail, and the same name field,
so those are shared now instead of parallel-implemented.
Plugins had no sanctioned way to ask for a file path — the OS door
carried notify, openExternal, revealPath and writeClipboard, so anything
needing a dialog had to reach around the SDK for window.hermesDesktop.
pickSavePath and pickOpenPath wrap the existing selectSavePath /
selectPaths IPC with the door's usual contract: resolve null when the
bridge is missing or the user cancels, never throw at the plugin.
An unreadable root self-heals on a 3s timer, so the probe runs for as long as
the pane is open. Every forced reload cleared `rootError`, emptied `data` and
dropped `resolvedCwd` before reading, so each tick rendered "unreadable" →
blank → "unreadable" and flickered the header's project name with it. A local
ENOENT resolves well inside the 180ms skeleton delay, so the gap paints as a
bare blank frame rather than a loading state.
Re-reading the same root now probes underneath what is on screen; only a
different root, or the same path from a different backend, clears first.
An unnamed `session.info` was treated as describing whatever the pane had
selected. The gateway stamps `stored_session_id: session_key or ""`, so every
not-yet-persisted session emits one, and `broadcast_session_info` / the
approvals loop re-emit for every live session at once. An unscoped event
applies exactly when no session is active, so with nothing selected each of
those repointed `$currentCwd` and claimed it for the null selection — the file
tree, coding rail and statusbar painted a folder no selected conversation
owned, until the next `releaseWorkspaceCwdOwner` dropped the claim and they
un-painted it.
Require the event to be bound to the pane's own runtime before an absent id
reads as the selection. The case the allowance exists for — a lazy session that
is the pane's runtime but is not persisted yet — still adopts and owns its cwd.
Composer drag added renderer CSS-pixel deltas onto a window AppKit
clamps to the current display, so the bar could not follow the cursor
onto a second monitor (and drifted on mixed-DPI Windows). Track the OS
cursor in main and lift that clamp.
Co-authored-by: Biotrioo <biotrioo@protonmail.com>
The locked dependency tree now carries @babel/* 8.x, which requires
node ^22.18.0 || >=24.11.0. Our engines.node arm said ^24.0.0 and the
installer gates (node_satisfies_build / Test-NodeVersionOk) accepted any
Node 24 — so a system Node 24.0–24.10 cleared every gate we own and then
failed 'npm install' with EBADENGINE under engine-strict=true.
- Raise the 24 arm to ^24.11.0 in root + desktop package.json and the
package-lock.json mirrors
- Tighten node_satisfies_build (install.sh) and Test-NodeVersionOk
(install.ps1) to 24.11+; update user-facing wording
- Add invariant tests: every engines.node arm floor must satisfy every
locked dependency's engines.node, and the installer gates must encode
the same floors as the manifest — so the next babel-style floor bump
turns into a CI red instead of a user install outage
- docs: correct stale 'Node.js v22' provisioning claim
FIRST_PAINT_BUDGET 20 + BACKFILL_STEP 60 prepended the rest of a 600-unit
page across ~10 visible commits. A 290-unit step keeps the interruptible
commits and removes the strobe.
Brand-new drafts are empty on purpose. A routed session the list already
knows has messages must not drop the loader just because a runtime id is
bound — that is the blank frame during an unproven warm hold and a cold
switch.
A compressed runtime cache is a legal tail, not display history. Publishing
it on session switch then replacing it with the persisted lineage is the
warm-path flicker. Gate that paint on persisted-display provenance and keep
the previous/empty view until REST authority lands.
Co-authored-by: xrbs00 <178640517+xrbs00@users.noreply.github.com>
GROUP_CHAT_MAX_ROUNDS and its four siblings carry over at the values
plugin.js shipped, so no rebase inherits a behavior change on top of a
rewrite. Making them configurable is live contributor work — #92213 for
per-room limits, #96842 for config plus a token budget — and both want the
same single seam, so say so where the constants are instead of adding a
config hook this PR has no consumer for.
Creating a bot opened its chat with the workspace fields omitted, because
they were spread only when the caller passed a staleness probe — and the
create path is the one caller that has none. The composer reads that scope to
stand its branch rail down in a companion chat, so a just-created bot showed
the git rail until the next click reopened the same row scoped. Live-verified
on Linux against a real backend, and carried over from the old plugin.js
rather than introduced by the rewrite.
The probe answers whether to navigate. What the session IS never depended on
it: a freshly minted Bot Chat is a bot's chat no matter who asked for it. With
the gate gone all four openers in this file are the same call, so they become
one.
The intro a new bot is born with was the first line of its forever-chat and
shipped in English, so a non-English user met their bot in a foreign language
and the bot's reply followed the prompt's language. It now resolves through
the plugin bundle in all four locales. Attribution — the other half of #91827
— still needs the lazy or silent birth that issue proposes, since
prompt.submit IS the user-turn API; the intro itself stays, per AGENTS.md.
The rest is the class the review named rather than only the lines it cited:
every user-visible string the group room and the bot-scoped cron pane own now
lives in the bundle. Where core already ships the vocabulary in every locale —
Remove, weekday names, Daily/Hourly — the plugin reuses it instead of shipping
a second, worse translation. The frequency and weekday option lists stop being
module consts frozen at import, which pinned whichever locale loaded first.
Prompts addressed to a model, cron syntax, and the 'You' author marker stay
hardcoded on purpose, each for a stated reason, recorded in the bundle header.
* fix(desktop): keep This-device Default on the local source
Selecting Default while This device is active took the legacy profile
door, which is also the window-primary key. On a VPS-primary desktop
that activated the remote gateway, so Bots showed the wrong Current
Gateway.
* test(desktop): pin Default on This device away from the window primary
Two sibling readers of raw config.mcp_servers duplicated their own (weaker)
shape guard: mcp-health.ts guarded the map but still passed null entries to
isUrlServer (crash on .url read), and the command palette re-implemented the
map check inline. Both now go through getServers(), the single choke point
that drops malformed entries, so a null entry can't crash the sweep and the
palette lists exactly the servers the MCP tab shows.
Also records the contributor email mapping for the salvaged commits.
Follow-up to the cherry-picked #94338.
`getServers` filters on whole-entry shape only — an entry with a junk field
(`{ command: 42, enabled: 'yes' }`) is still handed to the readers, which
coerce and tolerate. Pin that so a later tightening of `isEntry` can't
quietly start dropping entries that merely carry a bad field.
Raised in review on #94338.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An `mcp_servers` key left without a value parses as `null`, and the MCP tab
reads `enabled` straight off every entry (`serverEnabled`), so a single
dangling key threw during render and took the whole Capabilities workspace
with it — including the screen you would use to fix the config.
Filter non-object entries in `getServers`, the one choke point every reader
goes through, so a hand-edited config degrades to a missing row instead of a
dead pane. The backend already refuses to write such an entry
(`_replace_mcp_servers`), so this only has to survive a config edited
outside the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since 1e9a12a71 (v0.20.6), a remote/cloud/ssh registry PRIMARY makes
globalRemoteActive() true, so the ambient v1 route — and with it every
unpinned Capabilities read — resolves to the remote gateway. The only
road back to this machine is an explicit connectionId:'local' pin, but
capabilityScoped() deliberately DROPPED that pin (pre-#91564, absent id
always meant the local pool), and profileScopeKey() collapsed
'local::<profile>' to the bare profile key.
Consequences on any desktop whose registry primary is remote:
- Capabilities -> MCP showed the REMOTE host's mcp_servers under every
scope; locally configured (and connected) MCP servers vanished from
the UI entirely.
- Picking 'default - This device' in the scope selector was a silent
no-op: the collapsed cache key equaled the current scope key, so
changeScope() early-returned and the selector snapped back.
Fix: capabilityScoped() forwards EVERY non-empty connection id, 'local'
included — Electron's apiRequestRegistryConnectionId/ensureRegistryBackend
already own 'local' pins (forced-local pooled child) and this is the
documented contract there. profileScopeKey() namespaces every explicit
pin ('local::<profile>') so a This-device pick and the ambient path never
share a cache row. profiles.ts profileOwnerScoped, which hand-patched
this exact hole for profile mutations, reduces to a named alias.
Live A/B (Electron + CDP, remote registry primary, local-only servers in
local config): v0.20.6 = local servers invisible, local pick no-op;
fixed = 'This device' lists local-server-alpha/beta, remote scope
unchanged.
createCanonicalChat sends follow_profile_config and ensureGroupChatSession
sends room_plumbing + follow_profile_config again, as main's plugin.js did
before the rebuild. Without them, bot sessions created by this branch fell
back to the server's legacy title heuristics (exact 'Bot Chat' title;
hidden + 'Group: ' prefix) — the exact dependence the explicit contracts
were introduced to replace. Contract-shape tests pin both params.
Enough1122 review of #94426:
- Legacy Bot tiles persisted before ownerRoute.connectionId existed carry no
connection id, so the local-delete branch (`ownerConnection === 'local'`)
never matched them and they resurrected the deleted profile on relaunch.
Treat the empty connection id as the local connection.
- A route without profile now throws instead of silently falling into the
local-delete branch and dropping nothing remotely owned.
- Pin the 'local' connection spelling with tests covering the legacy
(no id), canonical ('local'), and divergent ('Local') spellings.
- Comment the live-atom filter arms by bucket and unify the duplicated
#94235 call-site comments in sdk/index.ts and delete-profile-dialog.tsx.
Address review findings on #94426:
- Non-route (local profile) deletes now require the tile's owner
connectionId to be 'local' — a same-named bot on another connection
keeps its tile and live conversation.
- Route identity (profile/targetProfile) goes through normalizeProfileKey
like the non-route name, so whitespace-padded identities match on both
paths; profile case stays exact, consistently on both paths.
- Test lint: drop the unused dropTilesForProfile value import (tests call
it via the fresh module namespace) and replace the forbidden typeof
import() type annotation with a type-only namespace import. New tests:
same-name-other-connection survival, whitespace normalization, and
case-exact identity. eslint: 0 errors; typecheck clean.
A Bot Mode bot cloned via 'Clone from profile' resurrects after
deletion: the desktop keeps the bot's chat tile in local storage
(bot-meta cleanup removes the roster entry, but the persisted tile
survives). On relaunch the tile restores, re-dials the deleted
profile's backend, and ensure_hermes_home() re-creates the profile
directory the delete just removed — the empty skeleton with no
config.yaml reported in #94235.
dropTilesForProfile() removes a deleted profile's session-tile
bucket and every Bot Mode tile whose ownerRoute points at it
(exact connection + backend target when a source-scoped route is
given) from memory and local storage, with discard (no Cmd+Shift+T
undo) semantics. Wired into both delete doors: the core
DeleteProfileDialog and the SDK host.deleteProfile path the Bots
plugin uses.
Regression tests cover local and source-scoped routes; verified red
with the cleanup disabled.
Bot-Mode canonical chats (the ONE forever DM per bot) and room plumbing
sessions are plugin-owned scratch conversations. They are now created with
an explicit follow_profile_config contract, persisted in the session row's
model_config, so session.resume rebuilds from the member profile's CURRENT
config instead of restoring the stored model/provider pin from an old row.
That stale pin is what left bot DMs stuck on a dead provider (e.g. 'out of
Nous credits' after the profile was switched to ollama-cloud) while the
same bot worked fine in rooms — the mirror image of the room-plumbing bug
(#89497 class). Normal 1:1 user chats keep the stored-runtime restore:
opening an older chat must show the model it actually used.
- tui_gateway/methods_session.py: accept follow_profile_config on session.create
- tui_gateway/server.py: persist the marker in the row; skip stored-runtime
overrides on resume when present
- apps/desktop/src/plugins/hermes-bots/plugin.js: send the contract from
createCanonicalChat and ensureGroupChatSession
- tests: backend override + row-persist coverage; desktop source-contract
coverage for both session kinds
Room member sessions in Bot Mode are per-member scratch conversations
inside a group chat. session.resume restored their stored model/provider
pin from the row's model_config, so a room bot stayed stuck on whatever
provider was pinned when the row was first written — even after the
profile was switched. Every room message then failed on the stale
provider (e.g. 'out of Nous credits' after switching a profile from
Nous to ollama-cloud) while the same bot worked fine in DMs.
Add an explicit room_plumbing contract:
- session.create accepts room_plumbing: true, persisted in model_config
- _stored_session_runtime_overrides() returns {} for marked rows, so a
room session always rebuilds from the member profile's CURRENT config
- hidden + 'Group:' title shape is kept as a legacy fallback for rows
created by older desktop builds that never sent the marker; hidden
non-room chats keep the stored-runtime restore
- Desktop Bot Mode sends room_plumbing: true when creating the hidden
per-member room sessions
Fixes#89497
The English bundle on the parent left the other locales to fall through.
Ship them the same way kanban does, using core's nouns, so a language
switch no longer paints the Bots rail in English.
Deleting the Bots home deleted the dead end that "Select a Bot or group
first." was apologizing for: with nothing selected the center is now an
ordinary session and + works there. The refusal still stands for the cases
that remain — a group room, and a selected row too orphaned to route.
Ownership of an empty transcript is per session and is not known until each
plugin has loaded its own data — which is why the slot mounts contributors
rather than resolving a claim up front. Taking only contributions[0] then let
a plugin that DECLINES a session suppress the one that owns it: silently,
permanently, and only for the users whose installed plugins happen to
register in that order.
Every registration is mounted and answers for itself. Declining is free; two
claiming one session render both, a visible conflict instead of a silent drop.
The composer subscribed to $sessionStates while isBotChatSession reads the
scope set — and, to resolve a live id to the stored one it is filed under,
$sessionTiles too. It stayed roughly right only because $sessionStates
republishes per streaming token, so the stale answer was overwritten within a
frame. A quiet session had no such luck.
Adds useStoresSelector beside useStoreSelector for the general case: a scalar
whose inputs span several stores, recomputed when any notifies, still
re-rendering only when the scalar changes.
The migration, its commit marker, and the rollback around it are all still
live in data.ts, but the suite covering them went out with the vm/regex
harness and was never ported — leaving only the happy-path commit ordering
asserted incidentally by bot-delete.
Nine cases: the sole-local topology gate (and the two refusals — multi-source,
and a batch where any one profile has no local route), the marker rule that
makes v2 authoritative (committed, markerless, crash-window), and the three
failed-commit paths (roll back to the last committed generation, clear both
keys when there is none, survive a failed cleanup).
migratedLocalRoutes is module-private, so where the old suite asserted the
map's size this one asserts what the map is for: no route adopted, nothing
written. Mutation-checked — dropping the marker rule fails three of them.
The adopt branch runs a full extra round-trip past the point every sibling
open in createCanonicalChat is probed at — registry miss, mint, title
conflict, re-consult — so it is the likeliest of them to land after the user
has clicked another bot, and it was the only one that navigated unguarded.
Adopting the winner still settles identity, which is always correct to
return; only the workspace steal is gated.