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.
Sid's ruling after walking the flow: both plugin families carry a pill.
"Help me make something in Blender" is offered everywhere (the catalog runs
Blender on all three platforms). "Set up my games and streaming" (NVIDIA App
+ Broadcast) is offered on a Windows PC with an NVIDIA GPU, where it leads,
since those plugins are Windows-only. Each task runs the install beat for
its own plugins.
Live run: the card showed no chips for 3 to 5 seconds, drew them, lost them,
and drew them again. connectors.list takes 6.7 to 8 s on this Mac, and the
catalog read another 6.7 s cold. The hook read once per MOUNT, and the card
remounts on every transcript rebuild (each hidden submit, each turn end), so
each remount threw the rows away and waited out another round trip.
Both reads now live in the query cache keyed by the guide session, and the
kickoff prefetches them when the guide session opens (created or adopted),
two turns before the card needs them. A remount paints the cached rows.
NS-960. One card, one group ("connectors"): the curated catalog plugins
this OS runs lead the hosted connectors (D1, D4). A plugin whose app is
absent is greyed with the reason and stays pickable (D5). Picks land in
answers.plugins and ride the existing [setup] note to the guide.
The guide's runbook gains the install beat as the last thing before the
handoff card (D2, D3): narrow the picks to what the chosen task needs, then
ONE manage_catalog install call. A new suggested first task sits beside the
existing ones and installs its plugins through that same beat: "Set up my
games and streaming" (NVIDIA App + Broadcast) on a Windows PC with an NVIDIA
GPU, "Help me make something in Blender" everywhere else.
When the guide's install card settles, each plugin row's outcome
(installed / failed / skipped, whatever the user did) is written into the
answers (D6). The build session's runbook names what is ready, what was
offered and not installed, and what was picked but not offered, and tells
the build agent never to install. profiles.remember_onboarding records the
picked plugin names in the default profile's memory, next to the connectors.
The onboarding card needs to list catalog plugins beside the hosted
connectors (NS-960 D1, D4) and grey a plugin whose app is absent (D5).
The catalog had no curated flag, and the manage_catalog row left
app_state empty.
- `onboarding: true` and `title` on catalog entries (loader, validator,
docs); set on blender, nvidia-app and nvidia-broadcast.
- hermes_cli/plugin_catalog_presence.py reads the plugin.json at the
catalog's pinned commit once per pin and judges its app declaration with
the hermes_platform resolver the installer and the Plugins-tab pill use.
No declaration or an unreadable one is `unknown`, never `present`.
- `plugins.manage action=onboarding` lists the curated entries this OS
runs (platform mismatch is the only exclusion) with app_state and the
sentence the card greys the row with.
- manage_catalog plugin rows now carry app_state and the catalog title.
- A live entry that differs from the in-tree entry at the same pin (new
metadata) now follows the same newer-catalog rule as a new pin, so a
checkout that adds `onboarding` is not masked by a published doc that
predates it.