fix(desktop): new chat honours the active profile instead of rubberbanding to default#45057
Merged
Merged
Conversation
…nding to default The top "New Session" button (and /new, the keyboard shortcut) cleared $newChatProfile to null, meaning "use the live gateway context". But createBackendSessionForSend turned a null into an omitted `profile` param on session.create. In global-remote mode one backend serves every profile, so an omitted profile silently binds the new chat to the launch (default) profile's home/state.db — the session "rubberbands back to default" even though the rail still shows the selected profile. The per-profile "+" worked because it sets $newChatProfile explicitly. Resolve a null $newChatProfile to the active gateway profile at the single session-creation chokepoint so session.create always carries the live profile. Harmless for single-profile and local-pooled users: a backend resolves its own launch profile to None (_profile_home), so passing it changes nothing.
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-assignment |
1 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
✅ Fixed issues (2):
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
run_agent.py:2886: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
Unchanged: 5701 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Collaborator
jhjaggars-hermes
added a commit
to jhjaggars/hermes-agent
that referenced
this pull request
Jun 13, 2026
* feat(desktop): composer status stack, live subagent windows, editable prompts (NousResearch#44630) * feat(desktop): session-scoped status stack + kill new-window theme flash Stack subagents, background tasks, and the queue into one collapsible "sink" above the composer, reusing the queue's chrome so every status reads as one piece. Extracts shared StatusSection / StatusRow / TerminalOutput primitives and a unified $statusItemsBySession store (subagents mirrored, background owned here, merged + grouped for render). Renames BrailleSpinner → GlyphSpinner now that it drives more than braille. Separately, fix the white flash on every new/cmd-clicked window: macOS `vibrancy` paints an NSVisualEffectView that follows the OS appearance and ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white until the renderer painted over it. Pin `nativeTheme.themeSource` to the app theme (persisted to userData so cold launches paint right before the renderer loads), hold windows with `show:false` until `ready-to-show`, and pre-paint the themed background via an inline script before the bundle runs. * feat(desktop): dock the slash popover to the composer via one shared fill var The slash·@ popover (and ? help) now docks onto the composer's edge with the same chrome as the queue/status stack — rounded outer corners, fused borderless edge, no shadow — but keeps its own narrow width. Surface + drawer paint a single --composer-fill var; the state ladder (rest / scrolled / focused / drawer-open) lives once in styles.css on [data-slot='composer-root']. The :has() drawer-open rule is last and forces an opaque fill, since translucent glass sampling different backdrops (thread vs fade gradient) can never match. This replaces the focus-within !important override that repainted the surface behind every previous matching attempt. Also drop the chevron column from the project file tree — the folder open/closed icon already carries the expand state. * feat(desktop): base inset for file tree rows (post-chevron alignment) * feat(desktop): wire the status stack's background tasks to the real process registry The background group was UI-only (dev-mock seeded). Now it's live e2e: - tui_gateway: new session-scoped `process.list` (registry snapshot filtered by the session's session_key, plus a 4KB output tail for the inline terminal viewer) and `process.kill` (single process, ownership-checked — unlike process.stop's kill_all). - Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store layout-stably — rows keep their position when state flips (never re-sort), new processes append, unchanged rows keep object identity so memoised rows skip re-rendering, and a dismissed-set stops the registry's retained finished procs from resurrecting X-ed rows. - Refresh triggers: session open, terminal/process tool.complete, status.update(kind=process) from the gateway's notification poller, and a 5s poll armed only while a running row is visible (catches silent exits). - Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side with resurrection guard. - Re-keyed the stack to the RUNTIME session id: it was keyed by the stored session id, where neither subagent events nor process.list would ever land. - Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit. Reconcile invariants covered in store/composer-status.test.ts. * feat(desktop): todos + openable subagents in the status stack, self-healing file tree - todo lists move out of the inline chat panel into the composer status stack (checklist icon, dashed ring = pending, spinner = in progress, check = done), fed live from todo tool events and seeded from history on session open - subagent rows carry the child's real session id end-to-end (delegate_tool → gateway → renderer) so clicking one opens ITS session window - status stack publishes its measured height so the thread's bottom clearance grows with it; card paints the shared --composer-fill so focused/scrolled states match the composer exactly - file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button, and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...) - composer drag-drop of tree entries inserts inline refs instead of attachments * fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone Sessions record their launch cwd; deleted worktrees leave that path dead, so opening such a session swapped the tree from the default workspace to a directory that ENOENTs forever — the 3s retry just spun on it. On a root read error the tree now asks main to sanitize the cwd (prefers the configured default project dir), displays that fallback, and quietly re-probes the original path so it switches back if the dir reappears. * feat(desktop): working restore-checkpoint button on past user prompts The discard icon on hover of a past user bubble was decorative — clicking did nothing. It's now a real control: a confirmation dialog explains that everything after the prompt is removed, then the session rewinds to that turn and reruns the same prompt (prompt.submit with truncate_before_user_ordinal, the same mechanism the edit composer uses). Failures rethrow into the dialog's inline error instead of toasting. * fix(desktop): show the restore-checkpoint button on the latest user prompt too Restoring the most recent prompt is just 'retry this turn' — no reason to exclude it. Stop still takes the slot while the turn is running. * fix(desktop): finished todo lists clear themselves out of the status stack A list whose every item is completed/cancelled lingers ~4s so the final checkmark is visible, then the todo group drops out of the stack. A fresh active list arriving within the linger cancels the scheduled clear. * chore(desktop): drop dead editableCheckpoint copy, terser restore confirm * fix(desktop): rewind clears the abandoned timeline's todos + background Restoring to (or editing) an earlier prompt rewinds the conversation, but the todos and background processes spawned by the now-discarded turns kept showing in the status stack — and the real background processes kept running. Both rewind paths now clear the session's todo rows and kill + drop its background processes before the fresh run repopulates them. Also drops the click-to-edit clamp transition, which flashed a half-expanded bubble on the way into the edit composer. * feat(desktop): user messages are always editable; edit/restore revert mid-stream The bubble is now always click-to-edit — even while a turn streams — instead of going inert during a run. Sending an edit acts like restore: it rewinds to that prompt and re-runs with the new text. Both edit and restore can fire mid-stream now; the gateway refuses prompt.submit while a turn runs (4009 "session busy"), so they interrupt the live turn first and retry the submit until the cooperative interrupt winds it down. Restore (re-run as-is) shows on every prompt except the latest running one, which keeps the Stop button. * fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh" The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so selecting text in the file preview pane and hitting it fell through to the terminal handler — which imported the right text but labelled the composer ref "zsh:N lines" off the shell name. When the selection isn't an xterm selection, label it with the previewed file instead. * fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging The source preview lets you select lines in the gutter and drag them into the composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line selection is active — it drops the identical ref instead of falling through to the terminal's global handler (which grabbed the native text selection and sent a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line selection there's no native selection, so the terminal handler stays out of it. * chore: gitignore apps/desktop/demo/ scratch output The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's throwaway, never part of the app. Ignore it so it stops cluttering git status. * feat(desktop): subagent watch windows, hard stop, sidebar hygiene Child-session mirror for live subagent windows, delegate sessions tagged and excluded from the sidebar, composer focus/stop polish, and WS stall resilience on the gateway transport. * refactor: DRY delegate SQL + trim status-stack noise Extract shared listable-child and delegate-delete helpers in hermes_state, collapse cancelRun busy release, and cut comment bloat in resume/status paths. * fix(desktop): hide orphaned subagent sessions in sidebar Cascade-delete all ephemeral children on parent delete (not just tagged rows), run v16 backfill to tag legacy orphans, and record new delegates as source=subagent. * fix: restore orphan contract for untagged children + lazy session eviction Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy), walk marker chains recursively with FK-safe orphaning, gate lazy watch sessions out of the still-starting eviction exemption via an explicit flag, pass session_id to _make_agent only when resuming, and hide source=subagent from session search. * fix(gateway): gate child mirror off upgraded sessions + age out stale run entries Review findings: the mirror could interleave synthetic events with a real native stream once a watch window upgrades (prompt.submit builds an agent), and a lost subagent.complete left _active_child_runs pinning running=true forever. Mirror now stops when the live session owns an agent; liveness reads ignore entries older than an hour. * fix(gateway): reject prompt.submit into a watch session while its child runs A lazy watch session's running flag is False (the run lives in the parent turn), so typing mid-run sailed past the busy guard and built a second agent racing the in-flight child on the same stored session. Busy error until the run completes; afterwards the submit upgrades into a normal conversation. * refactor(gateway): DRY watch-resume payload + compose listable-child SQL Fold the duplicated child-run busy overlay into one _reuse_live_payload helper across both resume reuse paths, collapse the twin mirror early-returns, and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it. * fix(desktop): clip horizontal overflow on sidebar scroll areas Add overflow-x-hidden alongside overflow-y-auto on session list scrollers and the shared SidebarContent primitive — vertical scroll unchanged. * fix(desktop): new chat honours the active profile instead of rubberbanding to default (NousResearch#45057) The top "New Session" button (and /new, the keyboard shortcut) cleared $newChatProfile to null, meaning "use the live gateway context". But createBackendSessionForSend turned a null into an omitted `profile` param on session.create. In global-remote mode one backend serves every profile, so an omitted profile silently binds the new chat to the launch (default) profile's home/state.db — the session "rubberbands back to default" even though the rail still shows the selected profile. The per-profile "+" worked because it sets $newChatProfile explicitly. Resolve a null $newChatProfile to the active gateway profile at the single session-creation chokepoint so session.create always carries the live profile. Harmless for single-profile and local-pooled users: a backend resolves its own launch profile to None (_profile_home), so passing it changes nothing. * docs(website): redirect old automation-templates URL to automation-blueprints The Automation Blueprints rebrand (NousResearch#44470) renamed the guide page from guides/automation-templates to guides/automation-blueprints, leaving the old URL 404ing. The site deploys to static hosting, so server-side redirects aren't available. Add @docusaurus/plugin-client-redirects (pinned 3.9.2, same as the other Docusaurus packages) and a redirect entry for the old slug. The plugin emits a static HTML page at the old path that meta-refresh/JS-redirects to the new page, preserving query string and hash, with a canonical link for SEO. Localized routes are handled automatically (zh-Hans verified). * feat(desktop): window translucency slider in Appearance settings (NousResearch#45086) A see-through-window control (0–100, off by default) that maps to the native window opacity via setOpacity — the desktop shows through the whole window, the same effect as the Windows shift-scroll trick. macOS + Windows; a no-op on Linux (no runtime window opacity). Renderer owns the value (persisted, nanostore) and mirrors it to the main process over IPC; main persists it to translucency.json so a cold launch applies it at window creation before the renderer reports in. * fix(ci): remove pytest-timeout, use per-file timeout only fix(ci): write a new cache for test durations every time change(ci): rip out error 4 retries because we found the real bug * fix(tests): mock subprocess.Popen in all _handle_update_command tests * fix(tests): guard against real 'hermes update' subprocess spawns in conftest Extends _live_system_guard in tests/conftest.py to block any subprocess call that would run 'hermes update' (or 'python -m hermes_cli.main update') against the real checkout. These commands run git fetch origin + git pull, overwriting repo files like pyproject.toml mid-test-run and corrupting every subsequent subprocess that reads them. The spawned process uses setsid / start_new_session=True so it's invisible to pytest's process tree (PPid=1) — the corruption was essentially undetectable without explicit inotify/SHA watchdogs. Root cause of NousResearch#43703 CI failures: tests in TestUpdateCommandPlatformGate called _handle_update_command() with HERMES_MANAGED='' and no Popen mock, causing the code to fall through and spawn a real 'hermes update --gateway' that overwrote pyproject.toml with origin/main's content (which still had '--timeout=30 --timeout-method=thread' in addopts while the PR had already removed pytest-timeout). The guard covers all three invocation patterns: - 'hermes update' / 'hermes update --gateway' (direct or via setsid bash -c) - 'python -m hermes_cli.main update --gateway' - '.venv/bin/hermes update' (absolute path variant) Does not false-positive on: git update-index, apt-get update, pip install --upgrade, or any command lacking 'hermes'/'hermes_cli'. * fix(tests): remove no-longer-needed forensics * fix(ci): only save test durations when tests pass The save-durations job used `if: always()` which meant it would run even when the test matrix failed, potentially caching duration data from a failed/incomplete run. Changed to check needs.test.result == 'success' so durations are only cached when all test slices pass cleanly. * refactor(desktop): use port 0 for ephemeral port discovery instead of PortPool reservation Replace the PortPool-based port reservation system (9120-9199 range) with OS-assigned ephemeral ports via --port 0. Before: Desktop probed a hardcoded port range, reserved ports in-process to close TOCTOU races, and passed the chosen port to the dashboard via CLI arg. After: Desktop spawns dashboard with --port 0, parses the actual port from a stdout announcement line (HERMES_DASHBOARD_READY port=<N>), and uses that for WebSocket connections. Changes: - web_server.py: add --port 0 support with SO_REUSEADDR pre-bind + announcement; add EADDRINUSE preflight for explicit ports - main.cjs: remove PortPool, PORT_FLOOR/CEILING, pickPort(), isPortAvailable(); add waitForDashboardPort() stdout parser - Delete port-pool.cjs and port-pool.test.cjs (106 lines removed) Net effect: eliminates the entire TOCTOU-mitigation reservation infrastructure and arbitrary port range constraints. OS handles port allocation natively. * Update model correctly when updating from dashboard * Update implementation to make it cleaner * Skip redundant model switch * fix(tui): config.yaml wins over env model seed in per-turn sync Hosted instances set HERMES_INFERENCE_MODEL as a provision-time seed in the container env. _config_model_target() previously went through _resolve_model() (env-first), so on hosted VPS the sync target stayed pinned to the seed and dashboard model changes never reached an open chat -- the exact scenario the sync exists to fix. The sync target now reads config.yaml first and only falls back to the env vars when config has no model. Startup resolution (_resolve_model) is unchanged. * Add Telegram Bot API 10.1 rich message support Introduce opportunistic support for Telegram Bot API 10.1 rich messages by sending raw agent Markdown via sendRichMessage and streaming previews via sendRichMessageDraft. Implements a rich-path fast‑path in gateway/platforms/telegram.py (RICH_MESSAGE_MAX_BYTES=32768, feature gate platforms.telegram.extra.rich_messages, bot capability checks, routing/thread handling, and conservative fallback rules: permanent/capability errors fall back to the legacy MarkdownV2 path, transient/network errors are surfaced without legacy-resend). Also add a latch for draft capability failures (_rich_draft_disabled) and preserve legacy chunking and draft behavior when needed. Update agent prompt hints (telegram encourages rich Markdown/tables), add CLI config example option, update English and Chinese docs to describe rich messages and fallbacks, and add/adjust tests for rich send and draft behavior. * fix: rich messages follow-ups — reply_parameters, send latch, opt-in default - Use reply_parameters per the sendRichMessage spec instead of the undocumented reply_to_message_id scalar (silently ignored -> reply anchor quietly dropped). - Latch rich sends off after an endpoint-capability failure (old PTB / server without sendRichMessage) so every later reply doesn't pay a doomed extra roundtrip; per-message BadRequests do NOT latch. - Default rich_messages to OFF (opt-in) while the day-old Bot API 10.1 endpoint is validated live; revert the prompt-hint table guidance until the default flips on. - Tests: reply_parameters shape, send-latch behavior, BadRequest non-latch; rich tests opt in explicitly via extra. * fix(send): helpful error when --file gets a binary; document MEDIA: attachments (NousResearch#45116) A user passing an image to `hermes send --file` got a raw UnicodeDecodeError ('utf-8 codec can't decode byte 0x89...') with no hint that media delivery goes through the MEDIA:<path> directive. - send_cmd: catch UnicodeDecodeError separately and print a usage error explaining --file is for text bodies, with copy-pasteable MEDIA: and [[as_document]] examples using the user's own path - --file help text + epilog now mention MEDIA: - docs: new 'Sending images and other media' section on the hermes send reference page * fix: stop Discord typing after replies * chore: add itsflownium to AUTHOR_MAP * test: assert typing-stop-before-callback as an invariant, not a call count The shared _stop_typing_refresh cleanup makes up to two bounded stop_typing attempts; the old assertion pinned exactly one typing-stopped event before callback-start. * fix(agent): re-enter retry loop on genuine Nous 429 so fallback guard runs The genuine-rate-limit branch set retry_count = max_retries before continue, intending the top-of-loop Nous guard to handle fallback or bail cleanly. But the loop condition is retry_count < max_retries, so the guard never ran: no fallback activation, no clean rate-limit message — just the generic retry-exhaustion error. Set retry_count = max(0, max_retries - 1) so the loop body runs exactly once more and the guard sees the breaker state recorded moments earlier. Extracted from the NousResearch#44061 bugfix rollup by @AIalliAI. * test: regression guard for Nous 429 fallback re-entry; AUTHOR_MAP entry * fix(update): never spawn an interactive polkit prompt when restarting a system-scope gateway (NousResearch#45145) When hermes update restarts a hermes-gateway system service as a non-root user, the systemctl reset-failed/start/restart calls trigger polkit's org.freedesktop.systemd1.manage-units TTY authentication agent. That prompt runs inside a captured subprocess with a 10-15s timeout, so it flashes and dies before the user can answer, and the resulting TimeoutExpired was swallowed silently by the loop's blanket except — the restart phase just vanished with no output. - Resolve a manage-units command prefix up front: plain systemctl as root, sudo -n systemctl as non-root (with a targeted reset-failed probe so least-privilege sudoers entries scoped to hermes-gateway* qualify), or None when no non-interactive privilege path exists. - Add --no-ask-password to every manage-units call in the update restart path so polkit can never prompt inside a captured subprocess. - When unprivileged: after a graceful drain, rely on systemd's own RestartSec auto-restart (needs no privileges) with a message about the wait; skip the force-restart fallback with clear manual instructions instead of racing a doomed polkit prompt. - Surface TimeoutExpired in the restart loop instead of passing silently, and add sudo to the system-scope recovery hints. - Docs: headless-VM note recommending user service + enable-linger, or sudo updates / a scoped NOPASSWD sudoers entry for system services. * fix(delegation): remove the default subagent wall-clock timeout (NousResearch#45149) Subagents doing legitimate heavy work (deep code reviews, research fan-outs, slow reasoning models) were routinely killed at the blanket 600s child_timeout_seconds cap while making steady progress (e.g. 36 API calls completed when the axe fell). Failures should come from what the child is actually doing — API errors, tool errors, iteration budget — not a delegation-level stopwatch. - DEFAULT_CHILD_TIMEOUT: 600 -> None; Future.result(timeout=None) blocks until the child finishes - config default delegation.child_timeout_seconds: 600 -> 0 (0/negative = disabled; positive opts back in, floor 30s unchanged) - stuck-child protection unchanged: the heartbeat staleness monitor still stops refreshing parent activity so the gateway inactivity timeout fires on a truly wedged worker; the 0-API-call diagnostic dump still works when a cap is configured - docs updated (EN + zh-Hans) * fix(dashboard): skill installs from the dashboard silently auto-cancel (NousResearch#45150) The dashboard's /api/skills/hub/install (and the new-profile hub_skills path) spawned `hermes skills install <id>` with stdin=DEVNULL but without --yes. do_install()'s 'Confirm [y/N]' prompt hit EOF, defaulted to 'n', and printed 'Installation cancelled.' into a background log the user never sees — every dashboard install no-opped. Pass --yes on both spawn sites, matching the uninstall endpoint which already passed --yes. The dashboard install button is the explicit user consent, same as the TUI/slash-command skip_confirm rationale. Repro: spawned the exact argv with stdin=DEVNULL against a temp HERMES_HOME — without --yes it cancels, with --yes the skill installs. * fix(compression): always append END OF CONTEXT SUMMARY marker to standalone summaries regardless of role When the compression summary lands as an assistant-role message (head ends with user), the end marker was not appended. Models may regurgitate the summary text as their own visible output when there's no clear boundary signal (NousResearch#33256). The end marker was already appended for user-role summaries (NousResearch#11475, NousResearch#14521) but the assistant-role path was missed in the original fix. This ensures ALL standalone summary messages carry the boundary marker, preventing summary text from leaking into user-visible chat output. * refactor(agent): hoist summary end marker to _SUMMARY_END_MARKER; strip it on rehydration Follow-up to the NousResearch#33346 cherry-pick: - the marker string was duplicated at both insertion sites (standalone + merged-into-tail); hoist to a module constant - _strip_summary_prefix now also strips a trailing end marker so a rehydrated handoff body doesn't leak the boundary directive into the iterative-update summarizer prompt (it is re-appended on insertion) * fix(profiles): exclude session history, backups, and snapshots from --clone-all (NousResearch#45246) --clone-all copied the source profile's state.db, sessions/, backups/, state-snapshots/, and checkpoints/ into the new profile. These are per-profile history: a 49GB copy in practice (15GB snapshots + 11GB backup archives + 16GB state.db + 6.4GB sessions), and restoring a copied backup inside the clone would resurrect the SOURCE profile's state. A clone is a fresh workspace; history stays with the source. New _CLONE_ALL_HISTORY_EXCLUDE_ROOT set, applied at root level for ANY source profile (named profiles accumulate the same artifacts), unlike the default-gated infrastructure excludes. Nested same-name dirs still copy. Docs and the post-create CLI message updated to match; profile export / hermes backup remain the full-history paths. * fix(compressor): keep last visible assistant reply out of compaction summary + label handoffs in WebUI (NousResearch#29824) Two-pronged fix for the WebUI "context compaction block in place of last assistant response" regression. Agent layer (the real fix). ``_find_tail_cut_by_tokens`` already had ``_ensure_last_user_message_in_tail`` to keep the most recent user request out of the compressed middle (NousResearch#10896), but no symmetric anchor for the assistant side. When the conversation has an oversized recent tool result or a long stretch of tool-call/result pairs *after* the assistant's last visible reply, the token-budget walk can stop with the previously-visible reply on the wrong side of ``cut_idx``. The summariser then rolls it into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block persisted as ``role="user"`` or ``role="assistant"``, and from the operator's perspective the WebUI session viewer (``web/src/pages/SessionsPage.tsx``) and the TUI chat panel both suddenly show the opaque "Context compaction" block in the slot where they were just reading the actual answer: User: "i cant see the output of the last message you sent, i did see it previously, however now see 'context compaction'" Added ``_ensure_last_assistant_message_in_tail`` mirror of the user-side anchor. It looks for the most recent assistant message with non-empty text content (skipping tool-call-only assistant "stubs" which the UI renders as small "calling tool X" indicators rather than a readable bubble) and walks ``cut_idx`` back through the standard ``_align_boundary_backward`` so we don't split a tool_call/result group that immediately precedes it. The two anchors are chained — each only walks ``cut_idx`` backward, so the tail can only grow. Falls back to "most recent assistant of any kind" only when no content-bearing reply exists in the compressible region (fresh multi-step tool sequence with no prior reply) — in that case the agent-side fix is effectively a no-op and the existing user-message anchor carries the load. WebUI layer (clarity). Added ``isCompactionMessage`` detector that recognises the ``[CONTEXT COMPACTION — REFERENCE ONLY]`` (current) and ``[CONTEXT SUMMARY]:`` (legacy) prefixes from ``agent/context_compressor.py``, and a new ``compaction`` entry in ``MessageBubble``'s ``ROLE_STYLES`` map. Compaction blocks now render as muted, italicised system-style rows labelled ``Context handoff`` — clearly metadata, not the assistant's actual reply — so an operator scrolling back through a long session can't mistake the summary for a real answer. Keeping the detected prefixes inline (rather than importing them) because the WebUI bundle has no Python interop. A guardrail comment points readers at the source-of-truth constants in ``agent/context_compressor.py``. * fix(webui): split merge-into-tail compaction so reply renders as its own bubble (NousResearch#29824) The compressor has a "double-collision" fallback path: when the chosen ``summary_role`` collides with the first tail message AND the flipped role would collide with the last head message, it can't emit a standalone summary turn (consecutive same-role messages break Anthropic and friends). It instead prepends the summary + end-of-summary marker to the first tail message's content via ``_merge_summary_into_tail``. With the matching anchor from the previous commit, that first tail message is now usually the user's previously-visible assistant reply — so the persisted assistant turn ends up shaped as ``[CONTEXT COMPACTION ...] ... --- END OF CONTEXT SUMMARY --- ... THE ACTUAL REPLY``. Without splitting it, the session viewer renders one big "Context handoff" bubble and the reply text is buried inside the metadata blob — which is exactly the "can't see the last reply" experience NousResearch#29824 reports, just one layer deeper. Added ``splitCompactionContent`` that detects the merge marker (kept in sync with ``--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---`` in ``agent/context_compressor.py``) and ``MessageBubble`` now recurses on the two halves: the prefix half renders as the muted "Context handoff" row, the remainder half renders with the original assistant styling. Pure (non-merged) summary messages hit the no-remainder branch and still render as a single "Context handoff" row, preserving the original behaviour. * test(compressor): regression coverage for assistant-tail anchor + compaction rollup (NousResearch#29824) 21 cases pinning the new ``_ensure_last_assistant_message_in_tail`` anchor and its interaction with the existing tail-cut path: * ``TestFindLastAssistantMessageIdx`` — helper contract: prefers a content-bearing assistant message, skips ``tool_calls``-only stubs, multimodal text-block content counts, falls back to "any assistant" when no content-bearing reply exists, honours ``head_end``, returns -1 when there's none. * ``TestEnsureLastAssistantMessageInTail`` — direct: no-op when already in the tail, walks ``cut_idx`` back when the reply is in the compressed middle, never crosses into the head region, re-aligns through a preceding ``tool_call`` / ``tool_result`` group instead of orphaning it. * ``TestFindTailCutByTokensAnchorsAssistant`` — integration: reporter repro (long tool-output run after the visible reply) now preserves the reply; user and assistant anchors compose in a single tail-cut call; a soft-ceiling-overrunning oversized tool result no longer strands the prior reply. * ``TestCompactionRollupReproduction`` — end-to-end through ``compress()`` with a stubbed ``_generate_summary``: the visible reply text survives either as its own standalone assistant message (normal path) or concatenated onto the merged summary tail (double-collision path the WebUI then re-splits). The standalone-summary case is asserted strictly (exactly one summary row, exactly one separate assistant row carrying the reply) — that's the dominant path and any drift there reintroduces the original bug. * ``TestSourceGuardrail`` — static asserts on ``agent/context_compressor.py``: the helper exists, the anchor is wired into ``_find_tail_cut_by_tokens`` AFTER the user-message anchor (so chaining is monotonic), the content-bearing preference is preserved, and the issue number is referenced so future bisects can find this fix. * fix(profiles): backfill .env for pre-existing profiles on hermes update (NousResearch#45247) Profiles created before NousResearch#44792 have no .env. Now that the Channels/Keys endpoints are profile-scoped (no os.environ fallback), those profiles would show everything as unconfigured. hermes update now copies the default install's .env into each named profile that lacks one (0600, never overwrites, placeholder fallback when the root has no .env), so existing users keep the credentials they were effectively running with. * feat(desktop): follow streaming output at bottom + jump-to-bottom button (NousResearch#45263) Strict sticky-bottom autoscroll for the chat thread: while the viewport is parked at the bottom, the tail follows content growth (streaming tokens, late measurement, Shiki re-highlight) via a useLayoutEffect keyed on the virtualizer's own size signal, pinned in the same pre-paint pass as its scrollToFn so the two never rubber-band. The gate is a single boolean — one upward pixel (scroll/wheel/touch) disarms follow until the user returns to the bottom. Adds a floating jump-to-bottom control that appears once scrolled ~10px away (above the dim threshold so a sub-pixel settle never flashes it), positioned above the composer with respect to the status stack, with a subtle scale + slide in/out animation that honours prefers-reduced-motion. The button bridges to the virtualizer's re-arm + pin path through a small nanostore emitter. Supersedes NousResearch#43624. * feat(desktop): worktree-aware sidebar grouping + composer/sidebar UX fixes Group recents as parent-repo → worktree → sessions using local git metadata (probed over IPC, with a path-name heuristic fallback for remote backends). Single-worktree repos collapse to one level. Sessions order by creation time and never reshuffle on new messages. Also: fuse the status stack to the composer border, restore icon actions in the queue panel, fix sidebar label truncation and drag styling, hide sticky-message attachments while pinned, and bump the terminal font. * feat(desktop): move workspace/worktree drag handle into the leading icon Mirror the session row: the repo/worktree header's leading glyph (repo mark, or a new git-branch mark for worktrees) swaps to a grabber on hover/drag instead of carrying a separate handle on the right — freeing header width for the label and + button. * fix(agent): preserve recent turns during compression * fix(agent): clamp flush cursor after repair_message_sequence compaction (NousResearch#44837) * fix(agent): rewind flush cursor exactly when repair compacts before the cursor Follow-up to the NousResearch#44837 clamp: a min() clamp only fixes cursor overshoot past the new end of the list. When repair_message_sequence drops/merges messages at indexes below the cursor, the clamp leaves the cursor pointing past unflushed rows and the turn-end flush silently skips them. Extract repair_message_sequence_with_cursor(): snapshot the flushed prefix by object identity before repair, then recompute the cursor as the count of surviving flushed messages. Falls back to the clamp when no snapshot is available. Keeps the safety guard in _flush_messages_to_session_db. Adds targeted tests for overshoot, before-cursor compaction, no-repair, bare-agent, and the flush guard. * refactor(desktop): extract shared WorkspaceHeader for repo + worktree rows The repo and worktree header rows were ~identical after the handle move. Fold them into one WorkspaceHeader (emphasis flag for the repo level) plus a small WorkspaceAddButton, so the toggle/handle/count/+ wiring lives in one place. * fix(skills): run youtube transcript helper through uv * fix(agent): add metadata flag to context compression summary messages (NousResearch#38389) Summary messages (standalone insertion and merge-into-tail) now carry a metadata flag so frontends (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user messages without content-prefix heuristics. Re-applied from PR NousResearch#38434 onto current main (conflicted with the _SUMMARY_END_MARKER hoist). Key renamed from the PR's 'is_compressed_summary' to '_compressed_summary': the wire sanitizers strip underscore-prefixed message keys, so the flag stays in-process and can never reach strict gateways (Fireworks/Mistral/Kimi reject unknown keys with 'Extra inputs are not permitted'). * test: compressed-summary metadata flag set in-process, stripped on wire * chore: add Kimi K2.7 code catalog slug (NousResearch#45283) * refactor(desktop): collapse sidebar drag-reorder into one generic ReorderableList Every reorderable surface (repos, worktrees, sessions, pins) now drops in a single ReorderableList that owns its own DndContext, so a drag only ever collides with that list's own items — nesting "just works" without leaking into the lists around or inside it. This replaces the shared DndContext + id-prefix dispatch (parent:/group:) whose closestCenter collisions resolved to a different-typed droppable and silently no-op'd worktree/repo drags. - Delete groupDndId/parentDndId/parse* helpers and the monolithic handleAgentDragEnd/handlePinnedDragEnd; each list persists its new id order via a direct typed write (reorderParents/reorderWorktree/reorderSessions/ reorderPinned). - Sessions inside repos/worktrees are date-ordered and static (no drag), matching the "never reorder on new messages" rule. - Add setPinnedSessionOrder; drop now-unused reorderPinnedSession. * fix(desktop): crisp terminal text via opaque xterm canvas The terminal looked soft/heavy on every platform because the xterm Terminal was built with allowTransparency: true, which drops the WebGL renderer's opaque fast-path and bakes glyphs as grayscale-alpha coverage for compositing over a see-through canvas. Our surface (--ui-bg-chrome) is opaque and withSurface already paints it, so transparency was pure blur for no benefit — VS Code keeps it off too. Also drop the Medium (500) base weight for normal/bold (400/700) to match VS Code's metrics, and remove the now-unused JetBrains Mono Medium face + woff2. * fix(desktop): stop streaming autoscroll bounce; move attachments below user bubble Streaming auto-follow chased content growth while parked at the bottom, which rubber-banded — the tail pin and the virtualizer's own measurement adjustments fought for scrollTop. Drop it; the one-time new-turn jump already lands a fresh message in view and the viewport stays put after. Attachments rendered inside the editable user bubble and were collapsed via an IntersectionObserver + [data-stuck] CSS hack while the bubble was pinned. Render them as a flow sibling BELOW the sticky bubble instead, so they scroll away behind it naturally — no observer, no collapse. Image refs still render as thumbnails, file refs as chips; no border. Removes the now-unused useStuckToTop hook and its CSS. * perf(desktop): isolate streaming re-renders & cut layout thrash During a token stream $messages is replaced ~30x/s. Subscribing the whole chat view to it re-rendered the composer, runtime boundary, and every message on every delta. - Derive coarse facts (empty thread? tail is user?) via nanostores `computed` atoms so per-token flushes don't re-render their consumers. - Move the $messages subscription + runtime wiring into a dedicated ChatRuntimeBoundary; the composer reads $messages imperatively. - Drive message rows off stable useAuiState selectors and a lazy getMessageText getter instead of eagerly materialized text. - Feed ResizeObserver entry sizes into measureClamp / FadeText and dedupe the style writes, killing the read-write-read reflow cascade. * perf(desktop): incremental markdown rendering during streams Re-parsing the full message markdown every reveal frame is O(N^2) over a long answer and dominated stream CPU. - Throttle useSmoothReveal commits to ~1 frame (REVEAL_MIN_COMMIT_MS). - Memoize block parsing with an LRU keyed on source text so only changed blocks re-parse. - Replace Streamdown's full-text parseIncompleteMarkdown with a tail-bounded remend: scan to the last top-level boundary outside fences/math and repair only the trailing open block. New remend-tail.ts is proven render-equivalent to full remend at every streaming prefix (remend-tail.test.ts), minus an intentional, documented divergence on cross-block dangling openers. * perf(desktop): faster session resume & warm AudioContext at idle - Resume: fire the REST transcript prefetch and the session.resume RPC in parallel, and skip the redundant message conversion + reconciliation when the prefetch already hydrated the transcript. - Haptics: web-haptics builds its AudioContext lazily on first trigger, paying the ~850ms CoreAudio spin-up on the first streamStart haptic as the first token paints. Open/close a throwaway context at idle so the real one connects to an already-warm audio service. * build(nix): refresh npmDepsHash for the remend dependency Adding remend changed package-lock.json, so the flake's pinned npm deps hash went stale and `nix flake check` failed. Bump it to match. * fix(desktop): theme the image-gen placeholder instead of a white square (NousResearch#45354) The diffusion placeholder read `--dt-*` tokens via `getComputedStyle().getPropertyValue()`, but those resolve through `var()` chains into `color-mix(in srgb, …)` — returned verbatim and unparseable, so every token fell to a hardcoded light fallback (white card). In dark mode the placeholder rendered as a white square. Resolve each token through a throwaway probe element's `color` so the browser computes it to a concrete color, and teach `parseColor` Chromium's `color(srgb r g b / a)` serialization. Re-resolve on theme repaint via a MutationObserver rather than per animation frame. * fix(desktop): stop stranding queued prompts across backend bounces A prompt typed mid-turn ("ghost bubble") could stick forever and never send when the backend restarted/reconnected during the turn. Two fragile assumptions in the composer queue drain caused it: 1. Drain fired ONLY on an observed busy true→false edge. A remount/ reconnect resets `previousBusyRef` to the current busy value, so the settle edge is swallowed and the queue never drains. Replace `shouldAutoDrainOnSettle` with the edge-independent `shouldAutoDrain` (idle + non-empty), driven on the settle edge, on mount/reconnect, and after a re-key. The drain lock still serializes sends. 2. The queue is keyed by `queueSessionKey || sessionId`. When a backend resume mints a new runtime session id for the same conversation, the entry strands under the dead key. Pass the *stable* stored id as `queueSessionKey` so the composer can tell runtime churn from a real session switch, and `migrateQueuedPrompts` re-keys pending entries on a runtime-id change only (never on a deliberate switch). Also make the drain resilient to a thrown/rejected onSubmit (e.g. a stale- session 404): the entry stays queued and is retried on the next idle, with a per-entry attempt cap (MAX_AUTO_DRAIN_ATTEMPTS) to avoid spin-loops and a quiet toast once it gives up. A manual send clears the backoff. Tests: composer-queue covers edge-free drain + re-key migration; use-prompt-actions covers rejected-drain-keeps-entry + idle retry sends. * fix(desktop): keep queued drains quiet on transient "session busy" A queued drain firing on the settle edge can race a not-yet-wound-down turn and get a transient 4009 "session busy". Previously that appended a red "session busy" error bubble (and toast) per attempt. For fromQueue submits, swallow the busy error: release busy, keep the entry queued, and let the composer's bounded auto-drain retry on the next idle. * fix(desktop): never surface "session busy" — retry every submit past it "Session busy" (4009) is the gateway's concurrency guard, not a user-facing error. The queue already covers the deliberate "type while busy" case, so the only leak was a submit racing the settle edge. Generalize the rewind path's busy-retry into a shared `withSessionBusyRetry` and wrap every `prompt.submit` (fresh send, session-resume resubmit, and rewind) so a transient busy is ridden out within a bounded deadline and the call lands silently. The fromQueue swallow stays as a backstop for the pathological >deadline case. * chore: uptick * fix(desktop): keep recents sorted unless manually reordered (NousResearch#45404) * Sync homelab/main to upstream/main with minimal carried patches Rebased onto upstream/main (9c50521) and reapplied the minimal homelab patch set: - Dockerfile: add iproute2 + GitHub CLI (gh) from official apt repo - pyproject.toml: add langfuse optional extra - plugins/observability/langfuse/__init__.py: Responses API serialization - plugins/platforms/discord/adapter.py: role-mention invocation support - tests/gateway/test_discord_role_mentions.py: role-mention test coverage - tests/plugins/test_langfuse_plugin.py: Responses API test coverage - .github/workflows/build.yml: GHCR image publish workflow --------- Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com> Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com> Co-authored-by: ethernet <arilotter@gmail.com> Co-authored-by: IAvecilla <ignacio.avecilla@lambdaclass.com> Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> Co-authored-by: ITheEqualizer <ali.zakaee.1997@gmail.com> Co-authored-by: Flownium <157689911+itsflownium@users.noreply.github.com> Co-authored-by: Aðalsteinn Helgason <adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local> Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com> Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com> Co-authored-by: konsisumer <der@konsi.org> Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com> Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com> Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the reported desktop bug: with a non-default profile selected, clicking the top New Session button (or
/new, or the keyboard shortcut) creates the new chat under the default profile — it "rubberbands back to default" even though the profile rail still shows the selected profile. Starting a chat via the per-profile + worked fine.Root cause
The two entry points diverge before any backend call:
newSessionInProfile()sets$newChatProfileto the target profile./new/ keybind → explicitly set$newChatProfiletonull, meaning "use the live gateway context".But
createBackendSessionForSend()turned anull$newChatProfileinto an omittedprofileparam onsession.create. In global-remote mode a single backend serves every profile, and the backend binds an omittedprofileto its launch (default) profile'sHERMES_HOME/state.db(_profile_home→None). So the new chat silently lands on default — exactly the symptom the user reported (and why it only showed up on a remote gateway).Fix
Resolve a
null$newChatProfileto the active gateway profile at the single session-creation chokepoint (createBackendSessionForSend), sosession.createalways carries the live profile. This covers every "plain new session" path (top button,/new, keyboard shortcut) at once.Safe for single-profile and local-pooled users: a backend resolves its own launch profile name to
Nonein_profile_home, so passingprofile: "default"(or the pooled profile's own name) changes nothing.Test plan
use-session-actions.test.tsxasserts the invariant: a plain new chat carries the active gateway profile onsession.create; an explicit per-profile selection is honoured; single-profile users pass"default"(backend resolves to launch).vitest run --environment jsdom src/app/session/hooks/— 48 passed.tsc --noEmitclean.