The salvaged gate only requested an explicit session.resume when the target
stored session was ALREADY selected — but the field failure (#89206) is the
cold open, where the persisted route points at the bot's session while
selection/runtime/transcript are all unsettled. The precondition skipped the
resume exactly when it was needed, and the hydration wait timed out into a
blank pane.
- sdk/index.ts: judge the main surface AFTER openSession() navigates, and
request a sequenced resume whenever the surface is not healthy (selected +
runtime bound + expected transcript present). Redundant requests are
consumed as no-ops by the route-resume effect.
- hermes-bots plugin: widen the fix to the sibling open path — the profile
session browser (openProfileSession) now opens with the same
awaitHydration/expectHistory contract as canonical Bot Chats, so a stale
main surface gets the same explicit resume instead of a silent blank pane.
- Regression test for the cold-open shape, proven failing against the
pre-fix gate (sabotage run) and passing with it.
Bold (SGR 1) and dim (SGR 2) are independent terminal attributes that
share a single reset code (SGR 22). ansi-tokenize's diffAnsiCodes models
'same endCode' as 'same slot' — emitting [2m over a bold cell yields
bold+dim instead of dim, and dropping a weight entirely emits nothing.
Every such transition leaves the real terminal diverged from the
StylePool's tracked state, and since later transitions are computed from
that phantom state the corruption compounds and sticks: random spans of
wrong weight/brightness that depend on which cells changed in which
order — the long-standing 'random dimness/opacity changes at whim' in
the TUI.
transitionAnsiCodes() wraps the diff: when a weight flag is removed,
reset the family with SGR 22 and re-apply the target's weights; pure
additions and non-weight styles keep the minimal library diff. Wired
into StylePool.transition (cached per-pair, hot diff path) and the
full-frame renderer.
Proven by an end-to-end probe (LogUpdate frames -> strict SGR
interpreter -> compare cell attrs vs the screen model): 18 divergent
cells on main, 0 with the fix.
Bot Mode group chats were named once at creation and could never be
renamed, and rooms had no picture — only the fanned member faces.
- New Group Chat dialog: optional room picture (upload from device or
image.generate, same 256px normalize pipeline as bot avatars).
- Room header: gear button opens Group settings — rename the group or
set/replace/remove the picture after creation.
- renameGroupChat re-keys the room record (log, watermarks, sessions,
members, picture), swaps the name in every local member's ui_meta
groups list, follows open views to the new name, and rejects
collisions instead of silently suffixing. Stored member sessions keep
resuming by sid, so no history is lost.
- Room picture persists in the durable room record, hydrates on window
load, and renders in the roster row (over the face pile), and the
room header.
A profile belongs to one gateway, but the Capabilities surface (Skills /
Tools / MCP) always read and wrote through the window's active backend —
scoping to a remote-owned profile silently edited the wrong machine.
- hermes.ts: capability REST helpers accept a ProfileScope
(string | {connectionId, profile}); ambient path now also carries the
active registry connection tag (same contract as the cron helpers,
#87882); profileScopeKey namespaces cache keys per connection.
- SkillsView: scope selector lists (profile, device) rows from the union
agent roster on multi-connection desktops; new fixedConnection prop
pins the whole view to a registered connection (plugin door), with a
probe-able SkillsView.supportsFixedConnection flag.
- MCP tab: live reload.mcp RPC withheld for cross-backend scopes (it
rides the active gateway socket and would reload the wrong machine).
- Bot Mode: remote-target drafts now get the live Capabilities tab
pinned to the target machine via fixedConnection, feature-detected so
older desktops keep the staged checklists.
- Config-record/hub-action stores accept scopes; cache keys fold in the
connection id so two gateways' same-named profiles never share rows.
Remote mode only offered Copy Path, which is a Linux server path and useless on the local machine. Reuse the existing gateway save bridge so a selected file can land on this computer.
Both entry points into the browser pane now ask for a reachable URL
first, so the dev server an agent names over a remote gateway actually
loads, and typing that address by hand behaves the same.
Every fallback keeps the original URL, which leaves the pane free to
explain an address it still cannot reach.
Only main can answer whether a loopback address is reachable: it holds
the live SSH connection and the profile precedence that picks it. The
renderer asks over IPC instead of guessing.
Forwards are scoped to the connection that authorized them and dropped
whenever it changes, so a new host never inherits a tunnel into the old
one. A failed forward logs and returns the original URL rather than
breaking the load path.
Opens a local->remote forward on demand so a gateway-side dev server URL
resolves to the gateway, not the laptop rendering the webview.
One lease per remote port, reused across pages and expired after 15
minutes; the port allowlist is deliberately absent, since the transport
we are already authenticated on is the security boundary and a curated
list just means the next framework default silently fails.
Lease and capability shape adapted from #87243.
Co-authored-by: tuancookiez-hub <tuancookiez-hub@users.noreply.github.com>
The scoped cmdk bar on Tools & Keys was a second search UI for the same
catalog ⌘K now serves. Cards stay listed; deep links still expand, scroll,
and flash the matching credential.
The composer renders one flat string, so a reference only became visible
after sending. It now wears the theme accent live, through both the cursor
and selection renderers; a masked input is a password and never highlights.
Two things the fast-echo bypass needed. It writes only the new cells, so a
keystroke that RECOLORS existing ones — `]` closing a token, a second `/`
demoting `/usr` to a path — has to take the Ink path instead. And its own
escape went through Ink's colorize rather than a hand-rolled truecolor
sequence: `38;2;` is unparseable on a 256-color terminal, where the accent
fell back to the default foreground and read gray.
A sent message accented a `/skill` named mid-prose and nothing else, so an
`@file:` ref and an `[[ Image 1 ]]` token flattened into body text. The
composer painted none of it.
splitComposerHighlights covers the whole vocabulary the desktop chips —
`/work` invoked or referenced, every `@ref` shape including quoted values,
and attachment/paste tokens — and both surfaces read it, so what you type
is what you see once it lands. Supersedes splitSlashSkillRefs.
Anything writing raw SGR past the renderer has to resolve a tone the same
way Ink does — chalk downgrades to the terminal's real depth, and Apple
Terminal takes a bespoke rich-8-bit path on top of that. Sharing the
renderer's own function is the only way a bypass can't drift from it.
* feat(desktop): add scoped credential command search
* feat(desktop): add global settings search
* feat(desktop): settings search lives in the command palette, deep everywhere
Replace the PR's sidebar search input with a settings page inside the ⌘K
palette (the subPages mechanism themes/pets already use). ⌘K on the
Settings overlay opens the palette scoped to settings; Back/Esc/Backspace
step out to the root. The deep catalog — schema-driven config fields,
appearance controls, stored credentials — now also serves the ROOT
palette's typed results, replacing the static section-key list, so ⌘K is
the same settings search everywhere. The input refocuses on page swaps,
and a seed store carries the keystroke that opened the palette so
type-to-search never loses the first character.
* feat(desktop): search pill rides the Settings card's top edge and hands off to ⌘K
The trigger is a fake pill — pure chrome, not an input — centered on the
overlay card's top border, half off the card (a new OverlayView edgeBadge
slot; a sibling of the card since the card clips its own corners). Click
or just start typing to open the scoped palette; while the palette is up
the pill scales up and fades out, then fades back when it closes.
Type-to-search forwards printable keys (outside editable targets) with
the keystroke seeded into the palette filter.
* feat(desktop): plugins are searchable — ⌘K 'kanban' lands on Settings → Plugins with the row flashed
The catalog stopped at config fields and credentials; installed plugins
were invisible to search. Both stores now feed it — desktop plugins from
$pluginRecords, agent plugins via the same plugins.manage RPC the Plugins
page fires (deduped by the store's inflight guard, filtered by the shared
desktop-relevance curation, which moves to the store so both callers use
one predicate). Rows deep-link as ?tab=plugins&plugin=<id|key> and the
page scroll-flashes the row via the shared useDeepLinkHighlight hook.
* style(desktop): satisfy perfectionist import/prop ordering
* test(desktop): add setApiRequestProfile to KeysSettings hermes mock
---------
Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Co-authored-by: Adolanium <adolanium@users.noreply.github.com>
The webview always runs on the user's machine, never on the gateway
host. Against a remote gateway an agent's http://localhost:5173 resolves
here — where that port is usually nothing, or somebody else's service —
and the pane just said 'Server not found'.
The load error now names the mismatch when the address is loopback and
the gateway is remote. It stays quiet on a local gateway and for
non-loopback hosts, which fail for ordinary reasons.
This explains the limitation rather than lifting it; forwarding the port
is a separate, larger job (see #87243 for the SSH case).
A non-empty composer used to lose to the busy-turn interrupt branch, so
Cmd/Ctrl+C while typing during a stream killed the agent. Clear first;
interrupt only when the input is already empty.
The Browser tab could only be summoned by asking the agent or clicking a
link. Adds Cmd+Shift+L and a command-palette row.
L is the location-bar chord every browser shares; plain Cmd+L is
already the terminal's selection shortcut, hence the shift. Pressing it
re-fronts whatever page is loaded rather than resetting it, so it reads
as 'show me the browser' and not 'start over'.
The application menu is only installed on macOS (#77845) — everywhere
else it is set to null, which strips the role accelerators with it. A
menu-only Cmd+R meant Windows and Linux got no page reload at all.
Moves the claim into the same before-input-event hook Cmd+W already uses
for exactly this reason, and drops the accelerator from the menu item so
one keypress can't fire both paths on macOS.
Clicking a link in the transcript, a url chip, or the terminal left
Hermes entirely. Now a web page opens in the browser pane — no context
switch to read a doc, and it's the surface the agent can see — while
Cmd/Ctrl-click and middle-click still escape to the real browser.
Routing lives in one helper so every link surface agrees. Anything that
isn't a web page (mailto:, file:, custom schemes) always hands off to
the OS, as do billing, OAuth, and local media, which need a real
logged-in browser.
The terminal keeps its own chord: it already spends Cmd/Ctrl on
activating the link at all, so Shift is what escapes there.
Cmd+R over a focused page reloads that page instead of the whole shell,
a trackpad swipe walks its history, and a mouse's back/forward buttons
do the same. Shift+Cmd+R still force-reloads the window.
All three arrive as native input, so main decides. A webview guest is
out-of-process: when the pointer and focus are inside the page, no
renderer signal sees it — not activeElement, not the layout tree's
hover/focus ladder. Main asks Electron for the focused webContents and
acts on it directly, and only falls through to the renderer when the
gesture landed on Hermes' own chrome.
Also adds a Reload window command to the palette, since Cmd+R no longer
always means that.
The Browser tab could only show whatever opened it: no back, no forward,
no way to type an address. Adds the row every browser has — back /
forward / reload / address — above the page.
The console and DevTools toggles move here from the zone strip. They act
on one page, so they belong beside its address rather than on a strip
shared with every other tab; that also frees the space the address bar
needed. Nothing else contributed strip tools, so the extension point
goes with them.
The group room's composer (new-thread box and reply-in-thread box) rendered
a plain SDK Input. The plugin's mention provider is registered against
COMPOSER_AREAS.atCompletions, which only mounts in the main chat composer —
workspace tiles never see it, so typing @ or / in a group chat did nothing
despite the placeholder advertising "@name to direct, @everyone for all".
Fix: a member-scoped GroupMentionInput wrapper in the plugin itself —
caret-aware @-token detection, popover offering @everyone/@all plus each
seated member's handle (same botHandle the parser uses), keyboard nav
(Up/Down/Enter/Tab/Escape), and insertion that produces exactly the
"@handle " strings parseGroupChatMentions resolves. Both group composers
(GroupChatWorkspace main box + thread reply box) mount it; the per-bot
composer path is untouched.
Root cause traced by the reporter of #89049 — confirmed against source.
`hermes profile rename default <name>` (and the Desktop/dashboard rename
flows) now set a presentation-only `display_name` in profile.yaml instead
of erroring. The canonical id stays "default"; resolution, comparison,
and spawn paths are untouched. Named profiles keep real renames and their
display_name survives the move.
Surfaces: profile list/show/status, /profile (text only — data.profile
stays canonical), dashboard ProfilesPage, TUI-gateway profiles.list, and
Desktop (rail, switcher, Manage page, and the Bot Mode roster via a
displayName fallback so a renamed default shows its name, not "default").
Slimmer redo of the direction in PR #87760 by @yxssxn — thanks; see PR
body for what changed vs that approach.
Supersedes the display-only conversation folding from #89030 with actual
threads, per Teknium's direction: guessing topic boundaries from user-
message timing was wrong — tasks take many user turns.
- Every room entry carries a thread id. The main composer STARTS a new
thread with the whole group ('New Thread'); each open thread has its own
'Reply in thread' box that CONTINUES that work. Explicit intent, no
heuristics.
- Member turns are thread-scoped end to end: the round-robin drive filters
the room log to the triggering thread, watermarks key on
thread::member, and responder resolution reads only that thread — so
parallel topics never leak into each other's deltas or eat each other's
watermarks.
- Stranded (timed-out) replies remember their thread and harvest back into
it; pre-thread bare-number markers still parse.
- Pre-thread logs hydrate through assignLegacyThreads(): a user message
after a 15-min lull starts a synthetic thread, follow-ups inside the
window stay together — one-time conversion only, live sends always mint
real ids.
- UI: threads ordered by last activity, newest open by default, older ones
fold to summary rows (head text, reply count, last activity) with
expand/collapse.
Tests: thread minting, explicit-thread continuation + delta scoping (other
thread's text never reaches the member prompt), legacy hydration split
behavior, thread UI source contracts. 258/258 plugin tests pass.
test_real_aiagent_builds_section_once_and_keeps_it_out_of_static_prefix
builds the system prompt twice and asserts byte equality, but the prompt
embeds build_coding_workspace_block() — live git status/log output. A git
call failing between the two builds (xdist contention in CI) makes the
Branch/Recent-commits lines differ and fails the test on unrelated PRs
(first seen on #89027's run: diff showed only '- Branch: (detached HEAD)'
and recent-commit lines).
Mechanism reproduced locally: with coding posture on and cwd inside the
checkout, failing git calls on the second build only => first != rebuilt;
with the snapshot pinned, identical git failure => byte-equal. The real
block's byte-stability is coding_context's own contract; this test is
about plugin sections.
Per review: even with an active sandbox env, spilled tool results
belong in $HERMES_HOME/cache/spillover with the other Hermes-owned
caches — not the sandbox temp dir as primary storage.
- Host-side write happens first on every backend; local/no-env
sessions reference the host path directly (unchanged).
- cache/spillover joins the auto-mount/sync cache-dir list
(credential_files._CACHE_DIRS), so docker bind-mounts it and
modal/ssh/daytona file-sync it. Remote references use the
translated in-sandbox path after a readability probe.
- Probe failure (persistent containers created before spillover
joined the mount list, translation failures) falls back to the
previous in-sandbox temp-dir copy, so nothing regresses.
The steer-survives-budget tests pinned the inline 'Truncated:' fallback
shape, which only occurred because env=None persistence was broken.
Now that host-side spillover succeeds, budget enforcement produces a
<persisted-output> block instead. Assert the actual contract — the
oversized payload was replaced (persisted OR truncated) — via a shared
helper, not which replacement shape was used.
Sessions that never ran a terminal command (MCP-only, cron, gateway)
have no active sandbox environment, so maybe_persist_tool_result()
got env=None and fell through to the inline-truncate fallback --
a 467K MCP result was cut to a ~1.3K preview with no file written
('Full output could not be saved to sandbox').
Now the host-side cases (env=None or the local backend) write the
spill file directly to $HERMES_HOME/cache/spillover/<id>.txt,
alongside the other Hermes-owned caches instead of littering /tmp.
Remote backends (docker/ssh/modal/daytona) keep the in-sandbox
env.execute() write since read_file resolves in-sandbox there.
Cleanup: the gateway housekeeping loop prunes spillover hourly with
the other media caches, and a once-per-process best-effort prune on
first spill covers CLI-only installs.
acc614e72 added a raw backslash inside a <code> span in a table row;
MDX reads it as an escape and never finds the closing </code>, failing
docs-site-checks on main and every open PR. Escape it as \.
Every USER message starts a conversation (the same boundary the turn
engine's delta scoping keys on). The latest conversation renders in full;
older ones collapse to a one-line summary row — head text, reply count,
last-activity time — that expands/collapses on click. db's 'threads per
conversation' ask, delivered as timeline folding: no log-model change, no
new persistence, cross-machine sync untouched.
groupChatConversations() splits the log (leading member run from a trimmed
log forms a headless block); rendering extracts renderEntry() unchanged.
Tests: conversation splitting (headless block, heads, startIndex) + fold
source contracts. 256/256 plugin tests pass.
nextSessionTileForWorkspace() only walked tabs stacked WITH the workspace
tab. In a side-by-side layout (session tiles in their own zones — db's
three-pane report), the walk found nothing, closeWorkspaceTab() skipped
promotion, and requestFreshSession() dropped main to a new draft: closing
a pane read as 'it gave me a new session'. A tile in any zone now promotes
(its zone collapses via the tile close), so Close is also the path from N
panes back to 1. Ghost panes whose tile is gone still never promote.
test_session_chat_stream_treats_pre_existing_poisoned_row_as_no_model
asserted mock_run.call_args right after the 200 status, but the stream
handler runs _run_agent inside asyncio.create_task(_run_and_signal())
and response.prepare() returns the 200 before that task necessarily
starts — on loaded CI runners call_args was still None (TypeError:
cannot unpack non-iterable NoneType). Draining the SSE body (resp.text())
joins the stream end, which guarantees the runner task completed.
test_goal_verdict_send used fixed asyncio.sleep(0.05) waits before
asserting on sends/enqueues produced by spawned tasks; replaced with a
bounded _drain_until() poll (5s cap, returns as soon as the condition
holds) so the asserts stay exact without the fixed-delay race.
These three tests red-flagged unrelated main pushes and PR runs on
Aug 18 (runs 32099139396, 32101135100, 32106224479, 32101070064).
With multi-group membership, a bot's meta carries groups[]; the create
dialog's taken-name scan must union all of them (botGroups) or a name only
present as a secondary membership could be reused and resurrect that room.