* fix(state): publish structural state.db corruption as one profile-level state
A structurally corrupt state.db showed up differently on every surface: the
sidebar endpoint returned 200 with empty slices plus an errors row, /api/sessions
returned 500, /api/status said components.storage ok and readiness was green.
None of them said the store was damaged, so Desktop rendered it as deleted
history (#72046).
hermes_state_health is now the single latch, keyed by resolved state.db path:
- SessionDB._halt_db_corrupt, the SessionDB read helpers, the web profile
reader and the readiness probe publish into it, only for structural
corruption (not FTS-scoped damage, not the malformed-schema case the web
open path heals).
- gateway.readiness reports it (state_db degraded/corrupt, and session_store
unavailable/corrupt even when the handle cache says ok), which also feeds
/api/status components.storage (now with reason: corrupt).
- /api/sessions, /api/profiles/sessions and /api/profiles/sessions/sidebar
carry storage: {profile: "corrupt"}; /api/sessions returns 503
state_db_corrupt instead of 500.
- A peer SessionDB handle in the same process refuses writes on a latched
path with the existing StateDbCorruptError, so gateway/agent transcript
diversion and classify_persistence_error keep working unchanged.
The latch never clears on its own and resets on restart, the recovery boundary
StateDbCorruptError already documents.
Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>
* fix(desktop): say the session store is damaged instead of an empty sidebar
The sidebar reads the list endpoints' new storage map into
$corruptSessionStores and renders a persistent destructive Alert above the
session list naming the affected profile(s). The copy says missing chats were
not deleted and points at the non-destructive path (quit Hermes, then
`hermes sessions recover --source <state.db> --inspect-only` or restore a
snapshot) plus the recovery guide; it does not recommend `sessions repair`
for structural damage.
Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>
---------
Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>
When submit cannot prove the pane's runtime owns the selected stored session
(reverse binding lost to eviction, reconnect, or a compression rotation the
selection never followed), it resumes the stored session and continues on
the runtime id that session.resume returns. That path pinned only
activeSessionIdRef. ChatView renders the $sessionStates slice named by
$activeSessionId, so the optimistic prompt, the reply, and every later turn
landed in a slice the pane never painted, while the legacy $messages mirror
(which the ref drives) looked correct. The chat stayed frozen until a relaunch.
Rebind the atom together with the ref, as the session-not-found recovery in
the same file already does, and carry the transcript the pane was showing
into the resumed runtime's empty slice (session.resume omits messages) when
both name the same conversation by lineage. A pane runtime that belongs to a
different stored session is never carried over.
Refs #71733
Refs #117867
Regression tests from #118719: a failed turn the re-submitted prompt truncated
out of the stored history returns to its timeline position, and a failed tail
whose retry is still local-only stays at the end.
preserveLocalAssistantErrors appended every kept local error run after the
refreshed transcript, so an older failed turn repainted below newer turns
(#118002). Reseat each run after the refreshed row that preceded it locally,
and drop a run the refresh already stored under new ids (same role/text
sequence in the gap).
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
The Bot Mode relay drains every registered connection every 30s. The local
route is exempt from relay socket retention (#93594), so each tick dials a
fresh requestGatewayForAgent('local', ...) secondary and disposes it after
the RPC. From the second tick, openSecondary saw the scope in
openedSecondaryScopes, treated the dial as a backend reopen, and called
resetTileRuntimeBindings (added in 613822afff), which drops the runtime of
every tile without an owner route plus the delegate's stored->runtime cache.
Those tiles ride the ambient gateway, so every split pane re-resumed and
remounted its composer every ~30s: caret reset, layout shift, and a reverted
composer model pick on an idle Desktop.
A reopened secondary that is not the window's active scope now resets only
tiles whose ownerRoute names that route (resetRouteOwnedTileRuntimeBindings
plus a drop-only dropRuntimeBindings delegate hook). The active-scope reopen
keeps the window-wide reset, so the stale-runtime guard from 613822afff still
covers the tiles that actually live on that socket.
Refs #108088, #118856
The labelled "Comment"/"Send" button floating inside a one-row compact
textarea read as part of the input and felt cramped. The field now
matches the new-project idea field, the closest analogue (a dialog
textarea with an inset icon action):
- default control padding, the Textarea's standard min height, and 13px
text to match the rendered comments;
- a ghost arrow-up icon button (the chat composer's send glyph) inset
`top-1 right-1`, labelled by aria-label and a Tip ("Comment", or "Send"
while the task runs);
- 16px between the last comment and the field, instead of the list's
12px rhythm.
The running task's requeue row below is unchanged.
- The main column inherits the dialog's conversation text size instead of
a stray text-sm, and its sections breathe on gap-5 so the description no
longer butts against the feed's tab strip.
- Comments sit on gap-3 now that each body is a rendered markdown block.
- The off-scale 0.71rem (diagnostics detail, ready-unassigned callout, run
rows) snaps to the 0.6875rem caption step used everywhere else here.
- Description, result, latest summary and comment bodies go through the
app's MessageTextContent renderer instead of a pre-wrapped <p>, so
agent-written `**Goal:**`, lists and inline code render as they do in
chat. `media={false}`: kanban text is not session-scoped, so `MEDIA:`
paths must not resolve against the active gateway.
- When the feed has more than comments, the segmented control is the
heading (help tip on the same row); the Section label above it only
repeated the active tab ("Comments · 2" twice). A comments-only feed
keeps its label.
- Comment rows put author and time on one line above the rendered body.
- Property rows are Sections, so every sidebar label (including Estimate and
Attachments) uses FIELD_LABEL and one gap rhythm instead of MetaRow's
own 0.65rem label style.
- Values wrap anywhere: the workspace path wrapped nowhere and was
hard-clipped at the modal edge. The raw `dir: ` prefix is gone; a
non-dir kind (scratch, worktree) is a muted badge, the path is mono and
has a copy button.
- Priority uses the board card's amber up-arrow glyph, extracted to a shared
PriorityGlyph so the two stay identical.
- The sidebar no longer starts ~1rem below the main column (py-4 vs no top
padding); both columns share the header's baseline.
- Truncated dependency chips reveal the full linked-task title in a Tip.
The task modal was a hand-rolled fixed overlay: off-token chrome
(--ui-stroke-tertiary border, --ui-bg-elevated fill, bg-black/45 backdrop),
no focus trap, a window-level Esc listener, and dropdowns that portalled to
body underneath the backdrop, which needed a z-(--z-modal-popover) rung on
each menu.
DialogContent owns the shadow-nous / --stroke-nous chrome, the blurred
overlay, focus trap, Esc and outside-click dismissal, and publishes itself as
the portal container, so the status, assignee, actions and model menus open
inside the dialog and the per-menu z-index workaround goes away. The title is
a DialogTitle (the dialog's accessible name), the header actions use Button
icon-xs ghost, and the body sizes to its content up to the old cap instead of
a fixed height that left dead space under short tasks.
The repo's no-native-title lint test forbids title= on buttons (use <Tip>
or aria-label). The chip's visible label already IS the linked task's
title, so the tooltip was redundant; aria-label carries it for a11y.
- Blocked by / Blocks move from the main column into the right property
sidebar (chips resolve titles via link_tasks, short-id fallback kept).
- Main column's Comments/Activity/Runs/Worker-log sections collapse into
one tabbed feed with a segmented control (Jira's 'Show: Comments |
History | Work log'), defaulting to Comments; tabs with no content are
omitted, and the control hides entirely when only comments exist.
The comment composer (live-steer + note-and-requeue) rides the
Comments tab.
- Desktop drawer -> Linear-style modal (smaller than Settings): main column
holds diagnostics, description, result/summary, dependencies, comments,
activity, runs, and the worker log tail; a right property sidebar holds the
inline editors (assignee, model override) plus priority/tenant/workspace/
created rows, estimate, and attachments. Backdrop click or Esc closes.
- GET /tasks/:id gains 'link_tasks' ({id,title,status} per linked task) so
Blocks/Blocked By chips render titles instead of raw ids; older backends
fall back to short ids. Additive; 'links' shape unchanged.
- New backend tests (test_kanban_link_tasks.py) + drawer tests for title
chips and the id fallback.
Review follow-ups. An agent preview of an HTML file whose tab the user had switched to Source stayed in Source, so the agent could believe the page was on screen; the preview-tool route and the status-stack row now pass an explicit rendered mode (renderedHtmlTarget), while Files-pane re-opens still keep the user's pick. The pane read the store tab a second time and kept a test-only local mode; it now reads target.renderMode and offers the toggle only on a tab-backed pane. PreviewRenderMode names the mode once; canRenderHtmlFile is the reduced form; a stale load error or annotate draft no longer flashes for a frame on the switch to Source.
Opening an already-open HTML file from Files re-normalised the tab to preview, so a Source pick was undone and the page ran again; openPreview now keeps the mode the tab is in unless the caller names one. Picking Source on a file with uncommitted changes landed on Diff, because LocalFilePreview mounted with no user mode and its auto mode is diff-first; the pane hand-off now counts as the pick.
Since HTML files open rendered from every source, the PreviewRecordSource argument no longer changed anything; every caller still passed one. Remove the argument, the type and PreviewAttachment's source prop.
Follow-up to the Render | Source toggle for Files-pane HTML: in source mode the pane rendered its own switcher bar above LocalFilePreview's header, so the file showed two stacked header rows where Markdown shows one. LocalFilePreview now takes `onSelectRendered`; when present it adds `rendered` to its own switcher (next to Edit, as for Markdown) and routes the selection back to the pane. The pane-level switcher renders only in rendered mode, above the browser bar.
Also gate the toggle on a renderable target: a remote HTML file whose data URL failed validation arrives as `{ renderMode: 'source', transient: true }` with no `dataUrl`, and offering PREVIEW there would load a `file://` URL of a remote path.
Test asserts PREVIEW and Edit share one header row in source mode, and that the transient source fallback offers no PREVIEW. Prettier on the two lines the base commit left unformatted.
Opening a local HTML file from Files now defaults to the existing
sandboxed preview path, with Source one click away on the same tab.
Co-authored-by: Cursor <cursoragent@cursor.com>
Watching the bot drive its screen is the point of Bot Screen, but until now
you learned it had happened only afterwards. With "Open Screen when the bot
uses it" checked on a bot's row menu, the first live tool.start for a screen
tool (computer_use, browser_*) on a session the bot owns brings its Screen
tab forward.
Why opt-in and fenced: Desktop's rule is offer, don't hijack. The raise is
per bot (BotMeta.screenAutoOpen, rides profile ui_meta like pin/hide), never
moves keyboard focus (openBotScreen reveals the pane; the viewer grabs keys
only on Take over), never fires for replayed history (the reconnect replay
re-dispatches parked frames, so the wake is rate-limited to one per bot per
30 s rather than trusting seq), does nothing while the tab is open, and a
manual Close mid-run holds until the run has been quiet for a cooldown.
Live (isolated headless Electron + real serve backend, CDP): opt-in off →
tool.start opens nothing; toggled via the real menu → toast + aria-checked
true → tool.start opens "Hermes · Screen" (Screen is off state), focus stays
on the row; second call is a no-op; real Close → held at +2 s and +29 s,
raised again at +31 s.
Rule borrowed from thomasbek3/hermes-bot-kit computer-viewer's auto-connect.
Restore the cheap repo-wide guards the per-file triage classed as source reads
but that protect recurring bug classes (<2s total):
- subprocess env scrubbing near spawn sites (credential leakage)
- gateway UTF-8 encoding= on file I/O (Windows mojibake)
- no raw yaml.safe_load of config.yaml (lost ${ENV} expansion)
- CLI subprocess.run timeouts (hung CLI)
- no locked readers on the shared state.db connection (#99349 segfault)
- CI classifier outputs / live-comment watch list match real workflows
- relay imports no platform crypto (relay trust boundary)
- Desktop relay deliver budget mirrors the Python deadlines (#93911)
- no native title= on Desktop buttons (DESIGN.md rule)
Drop _BASELINE entries in check_os_marker_fakes.py for files that no longer
fake macOS (the checker fails on stale entries), and remove doc/comment
pointers to deleted tests.
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
buildAppEnv sandboxed HERMES_HOME only. Desktop now attaches to the host's
running hermes serve (one backend per host) and profile roots are
HOME-anchored, so on a machine running Hermes a local spec either attached
to that backend or listed the real profiles, and its sends went through
the developer's real model and state.db instead of the mock provider.
Set HERMES_DESKTOP_ISOLATED_BACKEND=1 (the documented private-backend
escape hatch) and HOME=<sandbox root>. CI has neither a running backend
nor real profiles, so it never took this path.
Follow-up to the salvaged fix. Narrow the workspace live-reload gate to
loopback (full 127/8, ::1, 0.0.0.0, localhost) and drop `.local`: mDNS
names are other devices on the LAN (homeassistant.local), the same
"unrelated page loses its state on every agent file edit" class the fix
closes. The pane's error copy already had its own loopback regex for the
remote-gateway hint; both call sites now share isLoopbackPreviewUrl.
Tests trimmed to one pane invariant (reloads a loopback page, stops once
the tab browses elsewhere: the live page decides, not the tab's original
address) plus the predicate table.
The backend stamps `seq` once per event before its transport fan-out, so the
same frame arriving on two of the renderer's sockets to one process carries
the same (session_id, seq); `JsonRpcGateway` is per socket and cannot see the
other copy. `JsonRpcGateway.dispatchEvent` now tags each event with the
`replay_epoch` its socket adopted from `gateway.ready` (per process, not per
socket), and both fan-ins in `use-gateway-boot.ts` (primary + registry
secondaries) pass through one `createGatewayEventDedupe()` gate keyed by
(epoch, session_id, seq) before any store runs.
A duplicate is the same key within 30 s. Not "seq not above the highest
seen": the backend restarts a session's counter at 1 when the session leaves
its 64-session replay ring, and a high-water mark would swallow the whole
restart of any short session. Seq-less / session-less events always pass.
Bounded: 2048 seqs per session, 256 sessions LRU.
Live, with the router fix reverted so two sockets still join the chat: the
DOM never doubled a word (0/38 samples vs 4/40 on main) and the doubled
"reply was cut short" card is back to one.
Closes#120007. Part of #120005.
Since #118246 one local `hermes serve` serves every profile, and main marks
those routes `sharedPrimary: true`. `requestGatewayForProfile` honoured the
flag, but the session-owner family (`requestGatewayForAgent`, retain/open/
ensure) went through `isAttachedSharedRemote`, whose `primaryConnectionMode
=== 'local'` early return (#113956, written while every local profile had
its own pooled child) sent every non-default local profile to a registry
secondary: a SECOND WebSocket to the SAME backend process. The backend joins
any socket that sends a session call to the chat's transport fan-out, so the
renderer received every event twice — "HelloHello from from the the mock
mock…" while streaming, duplicate interim bubbles, doubled error cards
(#120005, #119131, #119566, #119540, #118934).
The predicate is now `ridesPrimaryBackend`: it asks main for the route on
every call and reuses the primary socket (with the `profile` param) when the
descriptor carries `sharedRemote` (#96493) OR `sharedPrimary` (#118246).
#101416 stays fixed: when the probe fails on a LOCAL primary we never fall
back to the primary (a pooled profile's chat must not be minted under the
primary's pid), and a pooled descriptor (HERMES_DESKTOP_ISOLATED_BACKEND=1)
still opens its own secondary.
Closes#120006. Part of #120005.
The Edit Models store persisted only an allowlist of visible provider::model keys.
Once a provider had any stored key it was skipped by the default expansion, so a
model that appeared later (plugin update shipping a new route, catalog refresh,
new release) was absent from the list and rendered switched off. The store could
not tell "hidden on purpose" from "never seen".
Persist a `known` snapshot (hermes.desktop.known-models) beside the allowlist,
recorded whenever the user persists a choice. A curated provider now admits
models absent from the snapshot through the same curated default rule
(featured list / top-N); a provider the user hid outright stays hidden, new
models included. Stores written before the snapshot existed adopt the catalog
as judged the first time it loads, so existing hide choices are honoured
verbatim and only later arrivals count as new.
The connect-first handoff (D85) has the build chat call manage_connections before
anything else, and the backend watcher mints each app's link on its first pass. That
moves the row to `initiated`, which the card drew as the spinner plus "Waiting for
your browser…" although nobody had clicked Connect and no browser had opened. Try
again had the same gap: the fresh link is minted, not opened.
The card now remembers which rows the user opened from it. A minted link the user has
not opened shows as not connected with the Connect button; clicking Connect opens it
and shows the waiting cue; Try again clears it until the new link is opened.
On Windows (run 3, 2026-09-23) the renderer's streamed copy of the setup
reply repeated its own chunks. The handoff card read its task and brief from
that copy, so the build session was titled "Set up mySet up my games an…" and
opened with "Set up Sid up Sid's PC's PC for gaming for gaming…", cut at 240
chars. The backend's stored reply held the clean directive.
The card now reads the handoff directive from session.history (the persisted
reply) and uses those values; an unreachable history falls back to the
rendered attrs. It also waits for the whole reply to stop running: `locked`
tracks only the text part, which a later part settles mid-reply.
Live run: the first default turn called skill_view("blender") and got
"Failed to load skill: blender". A plugin skill registers under its
qualified name (`agent-plugin-<key>:<skill>`), and the runbook carried only
the catalog name, so the model guessed the short one. The install card's row
already reports the qualified name; it now rides the settled outcome into
the runbook, which tells the build agent to load it by that exact name.