A status filter sits beside the Messaging search, with a tab only for tones
some platform is in (errors, needs attention, connected, inactive). Picking a
tab moves the detail pane into the filter; a platform that leaves the filter
because you just toggled it stays open.
Messaging and the gateway menu now read platform state through one
classifier, so the same bot can no longer show two different tones.
Co-authored-by: Sonnenwerk <a.majuskel@gmail.com>
A message.interim redelivered after the first copy sealed the stream took the
standalone-append path and painted the same commentary twice. Refresh the
newest same-text assistant row of the current turn in place instead,
comparing whitespace-normalized text.
Supersedes #120145 and #93962.
Co-authored-by: Brammiebram <64777864+Brammiebram@users.noreply.github.com>
'data-drop-hover' is a generic token any component could style; rename to
'data-session-drop-hover' to match the session-drag module that owns it
(same attr constant in session-drag.ts) so no foreign CSS can hit-test it.
Moves to the dropped project row, and stays a no-op over Home and the
session's own current project — the same exclusions the row's existing
"Move to project" menu applies.
Extends the existing pointer-based session drag (session-drag.ts) with a
new resolvable target: a sidebar project row (tagged data-sessions-project
by ProjectOverviewRow). Dropping a session there calls the same
moveSessionToProject RPC the row's own "Move to project" context-menu item
already uses, so this is a faster path onto an existing capability rather
than a new one. Home and the session's current project are excluded as
targets, matching the context-menu's own filtering. The hovered project
row is highlighted imperatively (a data-drop-hover attribute + an
outline-sidebar-ring style) to keep the sidebar out of React re-render
churn during pointer travel.
2ffa4977ba made combo.ts import keybindActionAllowedInEditableTarget
from actions.ts, while actions.ts already imported IS_MAC from
combo.ts. When actions.ts is evaluated first, its KEYBIND_ACTIONS
table reads IS_MAC before combo.ts has initialized it. The dev
renderer throws "Cannot access 'IS_MAC' before initialization" and
#root stays empty.
actions.ts now reads the platform from the leaf module
@/lib/platform (isMacPlatform), which chords.ts and other keybind
code already use. actions.ts no longer imports combo.ts, so the
cycle is gone. The three defaults resolve at module load exactly as
before.
`hermes logs --since` and `--level` passed every line that had no leading
timestamp. A traceback's frames are written without one, so an old error
printed its frames without the header that was filtered out, and
`--component` dropped the frames of a matching record. `hermes logs` now
reads each unstamped line as part of the record above it: the line gets
that record's verdict for every filter, in the tail read and in `-f`.
Lines before the first stamp in the read window have an unknown time and
level, so they are dropped when `--since` or `--level` is set.
mcp-stderr.log had no parseable stamp at all: the banner started with
`=====` and server output was copied raw, so `hermes logs mcp --since`
printed the whole file. The stderr tee already reads each server's
stderr in a thread, so it now writes one line at a time, each prefixed
with the asctime-shaped local stamp the Python logs use. The banner
starts with the same stamp. The stamp comes from new public
`timestamp()`/`stamp_line()` in hermes_cli/stderr_timestamp.py, which
stays stdlib-only. The desktop MCP log view accepts both banner shapes.
A test now requires a real writer's sample line for every LOG_FILES
entry to parse with `_parse_line_timestamp`. The docs no longer say the
`timezone` key changes log timestamps; log lines use the machine's
local time.
desktop.log stamped each line with an ISO-8601 UTC time in brackets
(`[2026-09-28T07:48:19.466Z]`), while agent.log, gui.log and errors.log use
Python's local-time asctime (`2026-09-28 13:18:46,062`). Reading one
incident across logs/ meant converting clocks by hand, and
`hermes logs desktop --since 1h` returned every desktop line, because its
timestamp regex expects the Python shape at the start of the line and
lines it cannot parse pass the filter.
formatLogStamp() now emits the Python shape in local time, and
formatDesktopLogLine() puts it first: `2026-09-28 13:18:46,062 [hermes] ...`.
The #84405 comment already said the stamp matched the Python convention;
now it does.
Keep the (connectionId, profile) tile keying and its two invariant tests;
drop the post-switch remembered-route re-navigation (separate scope, hook
latch juggling) and the two extra delete-path tests.
Trim the salvaged handler/test prose to the salvage bar and make the
contract match the handler: SessionSetHiddenParams.hidden is required
(no default=True), so the generated TS/OpenRPC types stop advertising an
optional flag whose absence used to hide the whole compression lineage.
Regenerated apps/shared/src/gateway-contract.*.
Same outcome as the salvaged commits — a restored tab whose id every
profile answered 404 for in calm conditions is dropped instead of being
re-probed on every launch — with less machinery:
- one set of scope listeners per backfill run instead of six per tile;
retired ids are tracked by storedSessionId, so a tile array rebuilt by
the backend-identity guard between start and gateway-open cannot make
the retire silently never fire.
- the "profile inventory known" gate lives in probeStoredSession, where
the ladder decides what its evidence proves, instead of in the caller.
- the nine-case test file is folded into session-tile.test.ts as two
invariants: retire on all-profile 404 (red on origin/main), keep on
5xx / network failure / switch in flight / profile A→B→A / connection
switch / stashed draft / unknown inventory.
Drop the second layer of #125356 (an `orphanedModelPick` reseed in
use-model-controls.ts): with the preview painted through the transient
setters the empty-provider row can no longer be written, and a fallback
that heals an unreachable state is defense-in-depth the salvage bar
excludes. The invariant test (preview never persists) stays.
An abandoned cold resume left the composer holding a manual model with an
empty provider in localStorage: the preview paint persisted both values,
and only a completed session.resume repaired them. Every new chat then
shipped the model alone, the gateway paired it with the profile provider,
and the coherence gate rejected the pair - the chat died on arrival and
the poisoned row survived restarts.
Paint the preview with the transient setters (the session.info heartbeat
path already does), and reseed a manual pick whose provider is empty from
the profile default so installs already carrying the stranded row recover
on the next launch.
Review findings on #126144 (independent review, all minor):
- pty_session.close_other_sessions closed a sibling tab's PTY under it without
closing its websocket: two tabs sharing one attach token on different
profiles left tab A silent until its next keystroke returned 1013. Close the
displaced viewer with the documented 4409 before closing the session. Still
closes attached siblings (skipping them would reopen the #125287 lease hole
via the old handler's finally: detach).
- openNewSessionTile paired projectProfile() with $newChatConnectionId — the
source captured with a stale new-chat pin — so a pin made on another
connection produced the right profile on the wrong host. Build the route
from the active source (resolveActiveSourceOwnerRoute) for the
project-derived profile.
- windows.test.ts: restore the no-parentSessionId case asserting the
viewed-profile fallback alongside the parent-routed case.
Trim-follow-up to the salvaged PR: document why ensureGatewayOpen asks the
socket itself instead of trusting the effect-mirrored gatewayStateRef, and
why the registry backs a not-yet-populated ref. No behaviour change.
Keep the substantive fix from #120899 (renderer carries the session owner's
connectionId, buildSessionWindowUrl serializes it, boot honors it for
win=secondary windows, child watch windows route via their parent) and trim
to the salvage bar:
- windows.ts: drop the fail-closed assertSessionOwnerResolved rung; an owner
that resolves nowhere keeps the documented active-profile fallback.
- main.ts: keep the session-window registry keyed by sessionId (one window per
chat) instead of (connectionId, profile, sessionId).
- windows.test.ts: fold the connectionId invariant into the existing owner
test instead of adding a second one.
The #79005 pin (projectProfile + pinNewChatProfile) lives in
startWorkspaceSession, which the sidebar project "+" only reaches while no
chat is loaded. With a chat occupied, startSessionInWorkspace stacks a tile
via openNewSessionTile({ cwd, listed: false }) and the owner fell through to
$newChatProfile || $activeGatewayProfile — a stale new-chat pin routed
session.create to the wrong (default) profile with the project's cwd.
Fix it once at the tile create: a tile anchored at a project path defaults
its profile to projectProfile() unless the caller named one. That also
covers the other project-cwd tile paths (project-row "+" drags via
onNewSessionSplit, the "new project" drop via $newProjectSessionRequest).
All-profiles view has no owner and keeps the old fallback.
Direction credit: @kokhlo (#124296) identified the skipped pin; this lands
the profile on the tile call instead of adding a DI'd door module.
Follow-up trim of the salvaged #125414 commits:
- fetchHistoryWindow (GET /api/sessions/{id}/messages/around) is the same
session-scoped read class and now carries sessionReadOwnerPin too.
- client.ts: drop the unused mode field and the sessionOwnerForRead wrapper,
shorten the seam comment.
- sessions.test.ts: keep two invariant tests (owner pin applied; explicit
caller pin authoritative).
- use-timeline-history.test.tsx: stub sessionReadOwnerPin in its client mock
so the hook test compiles against the new import.
A session-scoped read (detail / messages / timeline) dispatched on the
window's ambient connection scope: with a registered remote that exposes
a same-named profile, both directions answered 404 "Session not found"
for rows that verifiably exist on the other machine (#125372).
The renderer already persists the owner (tile route -> owner hint ->
connection-tagged row, resolved by knownOwnerForSession); the read path
never consulted it. Wire that resolver into api/ through a setter seam
(same pattern as setApiRequestProfile, no store->api cycle) and pin the
resolved connection on the three read helpers. An explicit (connection,
profile) caller scope stays authoritative; a bare-profile scope and the
ambient dial gain the owner's connection pin; reads with no resolvable
owner keep today's ambient behavior.
probeWindowsRemote took `$env:HERMES_HOME` at face value. A stale
User-scope value on the remote (install.ps1 persisted one before
47f4ab3a17 stopped doing so; nothing removes it) or a client path
leaked over the SSH channel then flowed straight into
assertSafeRemoteHome and every Windows remote failed with
"Unsafe remote Hermes home" even though the remote install was fine.
Only honour HERMES_HOME when it names an existing directory on the
remote; otherwise fall back to the remote's default
%LOCALAPPDATA%\hermes, exactly as an unset variable does. The
installer/updater half of the report is already fixed on main:
scripts/install.ps1 no longer calls
[Environment]::SetEnvironmentVariable("HERMES_HOME", ..., "User")
(47f4ab3a17), and the updater's "Refreshed Windows gateway launcher
scripts" step only rewrites gateway.cmd/.vbs with a process-scoped
`set HERMES_HOME=`; it never persisted anything.
Slim redo of #119022 by @MohamadKanso, which reached for the User/Machine
registry scopes instead of a remote existence check.
display-toggles now writes through requestGatewayForProfile (review fix for the #125969 class);
the tips test's @/store/gateway mock only exposed activeGateway, so the retire path threw on
the missing export. The mock forwards (method, params) so the existing assertions stand.
Independent-review findings on #126138 (all minor, all fixed):
- toolset-detail.tsx: `TerminalBackendPanel` now carries `key={profileScopeKey(profile)}`
like its sibling `ToolsetConfigPanel`, so a late `getTerminalBackends(A)` response
cannot repaint A's active backend under B after a profile switch (#96542 class).
- voice-live.ts `setVoiceChatMode` and display-toggles.ts `push`: both sent `config.set`
on the bare active socket with no `profile`, so under the shared-primary route
`voice.voice_chat_mode` and `display.*` landed in the launch profile's config.yaml
(#125969 class). Both now go through `requestGatewayForProfile(activeGatewayProfileKey(), …)`,
which adds `profile` exactly when the route shares the primary socket.
- use-statusbar-items.tsx: the approval-mode menu's now-scoped `config.get`/`config.set`
went through the session-routed ambient dispatcher, whose `routeParams` re-scopes
`params.profile` to the FOCUSED session's owner. The menu now dials
`requestGatewayForProfile(activeGatewayProfile, …)` directly.
Tests: display-toggles.test.ts asserts the routed profile (red on the PR head);
voice-live.test.ts is new (one assertion, red on the PR head).
The statusbar menu test renders `useApprovalModeStatusbarItem('work', …)` and
asserted the pre-#125969 unscoped shape `config.set {key, value}`. Since
setApprovalModeForProfile now scopes reads/writes to the viewed profile when
its name is non-blank, a write made while viewing "work" must carry
`profile: 'work'` — the old assertion pinned exactly the bug being fixed (an
unscoped write silently edits the launch profile).
Changed existing assertion (apps/desktop/src/app/shell/approval-mode-menu.test.tsx):
- `toHaveBeenCalledWith('config.set', { key: 'approvals.mode', value: 'manual' })`
-> same call with `profile: 'work'` added. The blank-profile (launch profile)
case, which must stay unscoped, is still covered by
src/store/approval-mode.test.ts.
Second half of the cross-profile token bleed: after A→B, A's in-flight
getMessagingPlatforms/getPairing could resolve and repaint A's platform/env
snapshot (redacted Telegram token included) under B until B's response won.
Responses whose scope is no longer the rendered one are now dropped. Also
restores the `useRef` import the salvaged commit removed — main's
refreshPlatformsRef still needs it.
Blanking the stale platforms state in a passive useEffect let the
'new scope + previous profile's data' frame paint first: for ~1s the new
profile's Messaging panel showed the previous profile's redacted
Telegram token as the field placeholder (password inputs render
placeholder text as plain text), then cleared once the per-profile fetch
landed (#96542). Passive effects fire after paint, so the effect-based
reset can never prevent that first stale frame.
Move the reset into render via the React-documented 'adjust state when
a prop changes' pattern (prev-scope comparison + setState during
render), which makes React discard the stale frame before it reaches
the screen. Same four states are cleared as before.
Fixes#96542
The vault page already routes every RPC through the (connection, profile)
owner from $settingsScopeProfile (de551dcd3a), but unlike Browser/Memory it
rendered no scope chip, so a non-default selection read as "the default vault
with my entries missing" and the user could not tell which vault Add/delete
targeted. Render the shared SettingsProfileScope selector like every other
profile-scoped settings page.
The desktop approval-mode store keyed its per-profile cache by name but sent
`config.get` / `config.set` without a `profile` param. The gateway's config
RPCs are `@_profile_scoped`, so an unscoped call resolves to the backend's
*launch* profile: the menu showed one profile's mode for every profile, and a
write made while viewing another profile silently edited the launch profile.
Name the profile each read/write is for (a blank name is the launch profile, so
omit the param and keep the backend's os.environ/session precedence) — the same
rule `api/client.ts`'s `profileScoped()` already uses for REST settings.