Commit Graph

2606 Commits

Author SHA1 Message Date
emozilla ad04a076bd feat(desktop): Local Models — one click from nothing to local tokens
The desktop surface for the managed local runtime:

- Local Models pane (Settings -> Providers): install the runtime, browse
  the catalog with per-machine fit pills (green fits-your-GPU / amber
  uses-system-RAM / red too-big, plus context start/max and vision),
  download with live byte progress, Use to make a model the default,
  eject and delete. Rows show residency live while the pane is visible —
  a stale 'Not in memory' next to a full GPU reads as a broken feature.
- Downloads and activations run through an app-level job store, so
  closing the pane (or reloading the app) never orphans a 20 GB
  download; completion and failure surface as toasts wherever the user
  is.
- Onboarding and the providers Accounts page offer 'Run models locally —
  no account needed' alongside cloud providers.
- System resources statusbar item (hidden by default): GPU utilization,
  GPU memory, and RAM, polled only while visible.
- i18n for en/zh/zh-hant/ja; Badge gains a success variant so fit state
  reads as a real traffic light.
2026-08-14 01:24:48 -04:00
Gille edb33be511 fix(desktop): persist dropped image bytes before attach 2026-08-13 21:24:10 -05:00
Gille 2707183fed fix(desktop): stop offering unsupported GitHub MCP OAuth 2026-08-13 20:55:45 -05:00
Brooklyn Nicholson 423f92e607 fix(desktop): connect pills reload tools into the session that clicked them
Both connect providers captured `sessionId` when the suggestion was built
and ignored the one the pill hands `invoke`. An offer that outlived a
session switch therefore aimed its `reload.mcp` at the session the draft was
sampled in, so the chat the user actually clicked from resumed without the
tools the pill just said were ready.

Prefer the invoking pill's session; the captured one stays as the fallback.
2026-08-13 18:31:29 -05:00
Brooklyn Nicholson 685a5c95ad fix(desktop): a withdrawn suggestion pill drops its phase and cancels its work
Phase lived in a `Record<key, phase>` that only ever grew, keyed by
`provider:id` — keys that repeat constantly, since a provider withdraws and
re-offers the same suggestion whenever the draft loses and regains its
trigger. A leftover `done` then painted a genuine new offer as "Added
GitHub" and swallowed clicks, because only `idle` invokes.

The same map outlived a session switch. One composer stays mounted across
it, so connecting GitHub in one chat left the next chat's real offer inert.

Withdrawal also stranded in-flight work. The pill is the only cancel
affordance — clicking a working pill sets the flag the provider polls — so
once it left the strip an OAuth flow could poll forever, hold the server's
in-progress slot against a retry, and resolve into a config write with no UI
left to narrate or roll it back.

Phase now lives and dies with the pill: withdrawn keys drop their phase and
flip their cancel flag, unmount cancels everything in flight, and the strip
remounts per session.
2026-08-13 18:31:29 -05:00
Brooklyn Nicholson 0280cf09c4 fix(desktop): suggestion pills paint the current offer, not the first one seen
The bus's change gate compared offers by `provider:id` alone. Providers
rebuild their suggestion objects on every draft sample, so that key is equal
constantly and the write bailed out — pinning the FIRST object for the life
of the offer.

Two consequences, both user-visible. The pill keeps painting a stale reason
("you mentioned linear" after the user pasted a linear.app link), and it
keeps calling a stale `invoke` closure — work built for a draft that no
longer exists.

Compare the fields the pill actually renders instead. The reference-identity
bail-out survives for the common case (same draft, same match, no re-render),
which is what the gate was there for.
2026-08-13 18:31:29 -05:00
Brooklyn Nicholson 8c8d55bd07 feat(desktop): grow the MCP suggestion directory to 18 official hosted remotes
Vercel, Supabase, Netlify, Hugging Face, Asana, Intercom, Airtable,
Webflow, PayPal, and Square join the directory. Every entry is a
vendor-operated remote with its docs page linked, same URL-only rule
as the founding eight. Trigger notes where words are ambiguous:
'square' the English word never fires (squareup only), and
vercel.app/netlify.app deploy-preview hosts are deliberately absent
(a pasted preview link is about the site, not the platform). Brand
glyphs wired for all newcomers.
2026-08-13 17:41:47 -05:00
Brooklyn Nicholson fb1ee93a63 fix(desktop): suggestion pills wait for a completed word and stand down on workspace homonyms
Two precision guards on the draft-keyword providers, both aimed at the
same annoyance: a pill firing while the trigger is still under the caret.

- Completed-word guard (mcp + skill): a whole-word keyword hit only
  counts once at least one character follows it, so the debounce
  elapsing mid-thought no longer pops a pill for the word being typed.
  Pasted-URL host hits are exempt: pasting is deliberate and the URL
  routinely ends the draft.
- Workspace homonym guard (skill): a skill named like the session's
  working directory is the project's name, not a request. Working in
  ~/www/hermes-agent no longer floats 'Use skill: hermes-agent' on
  every mention of the repo.
2026-08-13 17:41:47 -05:00
Teknium d16e2366df
fix(desktop): don't dial per-profile sockets for profiles served by the shared global-remote primary (#85665)
Under a global SSH/remote gateway, resolveProfileBackendRoute routes every
profile to the shared primary backend (case 3) and getConnection() returns
the primary descriptor tagged with the profile. ensureGatewayForProfile
still dialed a per-profile secondary socket at that descriptor; over SSH
the duplicate dial fails (per-backend tunnel/ticket) and the closed socket
became the ACTIVE gateway — every profile except the primary showed
'Hermes gateway is not connected' even though the primary socket was open.

Detect the shared-primary route and activate the primary socket instead;
$activeGatewayProfile still tracks the selected profile so per-request
?profile= scoping is unchanged. Hover pre-warm no-ops on this route.
Local pooled profiles and per-profile remote overrides are untouched
(pinned by test).
2026-08-13 15:06:00 -07:00
Brooklyn Nicholson e505ff9777 feat(desktop): add reset-to-defaults to the statusbar context menu
Once you have toggled a few items on and off there is no way to get
back to the shipped layout short of remembering which ids are in
STATUSBAR_HIDDEN_BY_DEFAULT. Add a row to the bar's right-click menu
that restores that set.

The row is disabled rather than hidden when nothing is customized, so
it also advertises that a shipped layout exists. Reset touches item
layout only — whole-bar visibility is a separate preference, and
resetting from the bar's own menu should not make the bar you are
right-clicking disappear.
2026-08-13 14:29:18 -05:00
Brooklyn Nicholson 120e465c97 fix(desktop): show statusbar by default
The whole-bar visibility atom defaulted to false (opt-in). Flip it to
true so the bar shows on first launch. The context-usage meter and
other diagnostic items remain hidden via STATUSBAR_HIDDEN_BY_DEFAULT,
so only the core status items (gateway health, model pill, command
center) appear out of the box. The toggle keybind and ⌘K row still
let users hide it.
2026-08-13 14:01:49 -05:00
fangliquanflq d0bb377c96
fix(installer): recover Windows setup when node-deps host exits abruptly (#81390)
* fix(installer): retry abrupt stage host exits

* fix(installer): preserve cancellation across stage retries
2026-08-13 13:38:44 -05:00
brooklyn! d753957e8a
fix(install): Windows setup no longer hangs forever on Node.js dependencies (#85529)
* fix(install): time-box the Windows node-deps stage so a stalled npm or Playwright install can't hang setup forever

scripts/install.sh has bounded this same work with run_with_timeout
"$NODE_DEPS_TIMEOUT" (600s default) since #39219, but install.ps1 never got
the guard: Install-NodeDeps ran both `npm install` and `npx playwright
install chromium` unbounded. A stalled registry fetch or a wedged Chromium
archive extraction (#76222, #84614) froze the installer indefinitely -- one
user left it running 12+ hours overnight before asking for help.

Route both invocations through _Invoke-NativeWithTimeout: cmd.exe launches
the native command with its output merged to a log, the parent polls with a
wall-clock deadline and tails new log lines to the console each tick (the
live progress that makes a 3-minute download distinguishable from a hang),
and on timeout taskkill /T /F kills the real process tree and returns 124 --
the same convention as coreutils timeout and bash's run_with_timeout.
Wait-Job was rejected for this: jobs swallow live output and Stop-Job leaves
the npm child running. Windows PowerShell 5.1-safe throughout.

Timeouts surface as a warning with the log path, a note that re-running the
installer resumes (stages are idempotent), and the NODE_DEPS_TIMEOUT env
override for slow links -- mirroring bash.

Fixes #76222.
Closes #84614.
Supersedes #76303.

Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>

* fix(installer): roll stage timers over to hours so an overnight stall doesn't read as "744 hours"

formatElapsed rendered a running stage as m:ss with unbounded minutes: a
node-deps stage left hanging overnight showed "744:38", which the user who
reported the hang understandably read as 744 hours. formatDuration
(completed stages) had the same unbounded-minutes shape.

Move both formatters into src/lib/format.ts (pure, no React) and add the
hour rollover: h:mm:ss live, "Xh Ym" completed. tests-js pins the shapes,
including 744m38s -> 12:24:38.

---------

Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
2026-08-13 13:38:20 -05:00
Brooklyn Nicholson fa92aa1984 fix(desktop): keep a markdown render failure inside its own message
MessageRenderBoundary re-throws anything that is not the transient
assistant-ui lookup race, by design, so a RangeError raised inside
Streamdown's render unwinds all the way to the workspace boundary and
replaces the entire app with "workspace failed to render". The message
is replayed from the session on every reload, so Retry lands on the same
content and fails the same way — the app is bricked, not glitching.

Wrap the markdown surface itself, so one bad message degrades to the
existing HugeTextFallback (readable, already used for oversized text)
while the rest of the transcript stays alive. The boundary sits on
MarkdownTextSurface rather than any single caller because the crash is a
property of the content, not of which part carries it: the same payload
arrives as an assistant answer, as reasoning, or in tool output, and all
of them render through here.

Tests drive the real component with both known overflow shapes and fail
with the reported RangeError when either half of the fix is reverted.
The depth clamp handles the raw-HTML cause; deeply nested block
structure recurses in mdast-to-hast where no HTML guard can reach it,
which is why the boundary is not redundant.

Co-authored-by: Gille <helix4u@users.noreply.github.com>
2026-08-13 13:36:54 -05:00
Brooklyn Nicholson 1641512c94 fix(desktop): bound raw HTML nesting before it reaches rehype-raw
Streamdown parses assistant markdown with allowDangerousHtml, so every
`<tag>` run in a message goes to parse5 and then through
hast-util-from-parse5, which recurses once per level of unclosed
nesting. Past roughly 1,750 consecutive unclosed tags that overflows the
call stack and throws RangeError out of the middle of a React render.

Nemotron-3-ultra degenerates into exactly that: thousands of `<unk>`
tokens emitted as reasoning, every one of them an element parse5 opens
and never closes. The payload is persisted to the session, so the throw
comes back on every reload.

Clamp the depth of unclosed elements in the prose path and escape the
opening `<` past the cap, leaving the text visible as the literal
`<unk>` it always was. The bound is on depth, not size: 20,000 balanced
`<b>x</b>` pairs and 20,000 void `<br>` tags parse fine because neither
drives the tree deeper, so only unclosed elements are counted and normal
markup is returned by identity.
2026-08-13 13:36:54 -05:00
Brooklyn Nicholson 7c48dfba79 feat(desktop): ship unified desktop halves opt-in; guard cross-root inventory rows
The unified agent-plugin root now loads its desktop halves disabled by
default — inventoried in Settings → Plugins, off until the user toggles
— so ~/.hermes/plugins keeps its installed-but-inert posture
(GHSA-mcfc-hp25-cjv7) on the desktop side too. The root-level cap only
lowers a plugin's own defaultEnabled; an explicit user enable still wins.

Also guards the folder-named error-record drop: with two roots, a broken
plugin folder can share its name with a healthy plugin's id from the
other root, and the unconditional drop clobbered the healthy inventory
row.
2026-08-13 13:18:49 -05:00
Brooklyn Nicholson 4c1365b6c4 feat(desktop): load a desktop half from unified agent-plugin packages
The disk-plugin door now scans two Electron-local roots through one
pipeline: the standalone <HERMES_HOME>/desktop-plugins/<name>/plugin.js
door, and <HERMES_HOME>/plugins/<name>/desktop/plugin.js — the desktop
half of a regular agent-plugin package. A feature that needs both SDKs
ships as one installable folder instead of two co-dependent plugins.

Records are keyed by entry-file path (folder names can collide across
roots), each root gets its own fs watch with the poll staying alive
until every root is covered, and older Electron shells without the new
agentPluginsRoot resolver simply skip the unified root.
2026-08-13 13:18:49 -05:00
Teknium 94be919411 feat: rename Codex OAuth provider label to "ChatGPT or Codex Subscription"
Renames the openai-codex provider's display label across the CLI
(hermes model picker, provider labels), the dashboard OAuth accounts
catalog, and the Desktop onboarding + settings provider pickers.
Slug, aliases, and auth flows are unchanged.
2026-08-13 03:06:08 -07:00
Teknium ae56c97c60 feat(desktop): show full session title in a tooltip when it truncates
Hovering a sidebar session row (one-line or card) now shows the complete
title in a styled tooltip — but only when the title actually overflows
its label, so fully visible titles never grow a redundant tip.

New OverflowTip primitive in ui/tooltip.tsx: a controlled Tip that
measures scrollWidth vs clientWidth on pointerenter and arms a 600ms
deliberate-hover delay only when the content is truncated. Community
request via @fhreire on X.
2026-08-13 02:38:32 -07:00
Zak B. Elep 5f5f8d5b62 fix(cli): drop agent-browser/@streamdown-math from root npm deps
`hermes update` was pruning root-level Node dependencies (agent-browser)
because npm ci always wipes and reifies node_modules according to its
active filter -- no root-first/workspace-first ordering or flag
combination (--workspaces=false, --include-workspace-root, etc.) can
reliably keep a root-only package.json dependency from being pruned by
a subsequent workspace-scoped npm ci. Confirmed empirically and via
npm/cli source (isArboristCmd hardcodes includeWorkspaceRoot=false for
ci/install), so no amount of install-order juggling fixes this for good.

Instead of chasing install order, remove the root-only dependencies
that made the npm step fragile in the first place:

- agent-browser is no longer a root package.json dependency. It
  resolves lazily via `npx agent-browser` (tools/browser_tool.py
  already had this as a fallback; it's now the primary path).
  warm_agent_browser_npx_cache() is called fire-and-forget from both
  `hermes update` and `hermes doctor --fix` to keep npx's cache warm,
  preserving the "available before any session starts" property
  agent-browser had as an eager dependency without re-entangling it
  with the npm workspace graph.
- @streamdown/math moves to apps/desktop/package.json, where it's
  actually imported (markdown-text.tsx, katex-memo.ts) -- it was
  never used anywhere else and was subject to the same pruning risk.
- _update_node_dependencies() collapses to a single
  `npm ci --workspace ui-tui --workspace web` call now that root has
  no dependencies of its own to protect, and keeps its original spot
  ahead of `_build_web_ui()` at both call sites in update_cmd.py --
  with no root-only dependencies left to protect, there's no reason
  for the Node refresh and the web build to run in any particular
  order relative to each other.
- hermes_cli/tools_config.py's post-setup Chromium-install path and
  hermes_cli/doctor.py's agent-browser check both now resolve through
  the same PATH -> Homebrew/Hermes-managed-node -> npx cascade
  (_find_agent_browser / _resolve_npx_bin) instead of hand-rolling
  their own node_modules/.bin lookups, so they can't diverge from what
  browser tools actually invoke at runtime.
- tests-js/package-json-lazy-deps.test.ts gets a lockfile-level check
  mirroring the existing camofox one, so a future regression that
  reintroduces agent-browser into package-lock.json fails this test
  directly instead of relying on manual review to catch it.

Fixes #43564.
2026-08-13 02:38:28 -07:00
hermes-seaeye[bot] cc389f8155
fmt(js): `npm run fix` on merge (#85175)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-13 09:21:50 +00:00
Teknium 926c8d591a feat(desktop): cron + blueprint recipes in the sidebar nav rail
Adds a 'Scheduled jobs' row to the sidebar's top nav (below Artifacts,
watch codicon, wired to the existing nav.cron keybind action) so the
cron overlay opens from the same rail as New session / Capabilities /
Messaging / Artifacts.

Inside the cron overlay, the list rail now also shows the Automation
Blueprint recipes below the jobs (same search box filters both).
Clicking a recipe opens the create dialog pre-seeded to that
blueprint's typed-slot form via a new optional blueprintKey on the
create EditorState. Catalog fetch reuses the ['cron-blueprints'] query
key, so no extra request.

i18n: sidebar.nav.cron added to en/zh/zh-hant/ja (ar already had it).
2026-08-13 02:12:23 -07:00
Teknium cfc5e098f2
fix(sdk): keep all-profiles sidebar scope on cross-profile openSession (#85155)
ensureGatewayProfile narrows the Sessions sidebar to the activated
profile as a side effect, so every cross-profile open from a plugin
surface silently locked the user into that profile's session list. A
plugin-driven open is a navigation, not a scope choice: openSession now
restores the unified all-profiles view after a cross-profile activation
(keepAllProfilesScope, default true; pass false for the old narrowing).
Same-profile opens write no scope at all.
2026-08-13 01:50:04 -07:00
hermes-seaeye[bot] 90c7180dce
fmt(js): `npm run fix` on merge (#85140)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-13 08:15:48 +00:00
kshitij a4f468e832 refactor(gateway/desktop): consent-first truncation precedence + dedup (simplify pass)
Final-diff simplify/review pass findings on #83785:

- Consent gate (confirm_truncate -> 4029) now checked BEFORE target
  resolution, restoring the pre-PR precedence: an unconfirmed submit
  carrying truncation params refuses without paying the durable-transcript
  read or heal-stamping live history dicts, and an unconfirmed out-of-range
  ordinal returns 4029 (not 4018). Malformed params still refuse first
  with 4004. Regression test added (spy DB asserts zero reads pre-consent;
  mutation-checked against the previous commit).
- _coerce_truncate_ordinal generalized to _coerce_truncate_int(param_name):
  the row_id branch was inlining the exact bool-guard + int() -> 4004
  pattern the helper had just extracted.
- Deleted the dead user_indices re-read after _resolve_truncate_row_id
  (heal mutates dicts in place; the filter output is identical) and the
  duplicate range check that had deadened the pre-existing guard.
- Desktop: exported isVisibleUserMessage from use-prompt-actions/utils and
  used it in visibleUserOrdinal / visibleUserIndexAtOrdinal /
  rebindSurvivorRowIds — one predicate for the ordinal parity all three
  depend on instead of three verbatim copies.
- Docs: programmatic-integration.md documents survivor_user_row_ids.
2026-08-13 13:35:55 +05:30
kshitij f6081b6647 style(desktop): satisfy perfectionist import order + padding lint rules
check:lint failed on the two sort-named-imports errors (survivorRowIdsFrom
before type SurvivorUserRowIds) introduced by the rebind commit; also adds
the blank line eslint wanted in the new test helpers.
2026-08-13 13:35:55 +05:30
kshitij 42eec4ab38 fix: return survivor row ids after rewind so clients can rebind stale rowIds
Review follow-up (StanleyStetson + egilewski on #83785/#83202): a successful
rewind's replace_messages(archive_dropped=True) re-inserts the surviving
prefix as NEW SQLite rows. Gateway memory picks up the fresh _row_id stamps
via lastrowid, but the Desktop's surviving bubbles kept their pre-rewind
ChatMessage.rowId — so a second rewind/edit/regenerate of an older surviving
turn sent a stale truncate_before_row_id and was (correctly) refused with
4018 until a transcript reload. Fail-closed stays untouched, per both
reviews; the fix is rebinding, not ordinal fallback.

Server: prompt.submit now returns survivor_user_row_ids (fresh post-rewrite
ids of surviving visible user turns, in visible-user-ordinal order) on both
the inline and compute-host paths whenever a durable truncation committed.

Desktop: runRewindSubmit surfaces the field; restore/edit/reload on both the
primary chat and session tiles rebind surviving user bubbles positionally
(same visible-user filter the ordinal math uses) and clear any rowId they
cannot rebind — a cleared id degrades to the ordinal path instead of a 4018.
Absent field (older gateway) leaves state untouched.

Tests: consecutive-rewind regression on a real SessionDB (stale id 4018s,
returned id succeeds; mutation-checked) + vitest for survivorRowIdsFrom /
rebindSurvivorRowIds (rebind, null-clear, past-end clear, hidden skip,
identity preservation).
2026-08-13 13:35:55 +05:30
kshitij 4aeb6f4a4f style(desktop): drop stray semicolons in rewind.ts
Three PR-introduced trailing semicolons in a semicolon-free file;
rewind.test.ts re-run green (5/5) with the change in place.
2026-08-13 13:35:55 +05:30
StanleyStetson 23da6d6fe2 fix(gateway/desktop): durable row-id addressing for rewind truncation
Address rewinds/edits via SQLite messages.id (truncate_before_row_id)
instead of shifting user ordinals. Resolve against in-memory stamps,
then durable session history when live turns drop _row_id; refuse
unknown durable targets with 4018 (no ordinal fallback) and 4030 on
ordinal/row_id mismatch. Stamp _row_id on insert, load row ids on
resume paths, send rowId from Desktop, filter renderer-synthetic ids,
and stop silently resending failed targeted edits without truncation.
Add production-shaped SessionDB tests for resolve and fail-closed paths.

Fixes #82959
2026-08-13 13:35:55 +05:30
Brooklyn Nicholson 9deb0302ca fix(desktop): the sidebar remembers grouping per workspace scope
The Project-grouping flag was one global bool while the grouping beneath it
was already stored per scope (workspace vs all-profiles). Picking Project
inside a workspace therefore dragged the all-profiles view into the project
tree and vice versa — "I have to re-set grouping every time I switch."

The flag now lives per scope like its sibling grouping atoms (the flat key
keeps its historical name so existing choices survive), setSidebarGrouping
writes to the scope it just switched INTO when Profile flips the view, and
reset clears both scopes.
2026-08-13 02:18:53 -05:00
Brooklyn Nicholson 9be9925467 fix(desktop): themed fade scrollbar on the virtual list, not overlay
scrollbar-overlay opts out of the themed thin scrollbar; on Windows there
are no native overlay scrollbars, so Chromium painted the classic
always-visible gutter instead — a permanent scrollbar next to the recents
list. The themed fade bar reserves its 4px on every platform but stays
invisible until hover, and the wrapper no longer stacks a second scroller,
which is what the overlay class was originally working around.
2026-08-13 02:18:53 -05:00
Brooklyn Nicholson 81587c4f8f fix(desktop): inbox cards render in every sidebar view, not just flat recents
The card prop was gated off whenever Project grouping was active, so the
Inbox style toggle silently did nothing there. It is a render variant, not
a grouping: project lanes and overview previews now render the same card
the flat list does.

Also mirrors the section's real virtualization inputs (projectOverview /
entered-project content, not the persistent agentProjectTree cache) when
deciding the wrapper's scroll classes, and stops gating SCROLL_Y on that
parallel guess — the section is the single authority on which scroller
lives, so the recents pane can no longer end up with no scroller at all
(the "no sessions under Updated grouping after toggling settings" blank).
2026-08-13 02:18:53 -05:00
Brooklyn Nicholson e7032bb267 feat(desktop): recurrence-to-cron suggestion provider
Third draft provider: recurring phrasing in the draft ("every morning",
"daily", "each week") offers a Schedule-this pill. Click prefixes the
draft with an explicit scheduling instruction and the agent creates the
job via its cronjob tool on send — the pill never schedules anything
itself. Proper-noun guard keeps titles like "the Daily Prophet" quiet;
hyphen-as-word-char keeps "weekly-report.pdf" quiet.
2026-08-13 02:17:42 -05:00
Brooklyn Nicholson c8cad1cc9e feat(desktop): skill-match and connection-repair suggestion providers
Two new sources on the suggestion bus, one per provider shape:

- skill (draft): the draft names an enabled skill (whole-word,
  4+ chars), so offer to lead the message with its /command. Invoke
  prefixes the draft via the new 'prefix' insert mode and stands down
  once the draft starts with a slash; skill_manage invalidates the
  cached index alongside the slash-completion cache.
- repair (event): an mcp__ tool call failing with auth/connection-
  shaped output offers a one-click reconnect for that server, fed from
  the gateway tool.complete handler. Reconnect runs the shared OAuth
  flow with server-side cancel and reloads live tools before claiming
  success; a later successful call to the same server withdraws the
  offer on its own.
2026-08-13 02:17:42 -05:00
Brooklyn Nicholson a3da6d8071 feat(desktop): quiet suggestions the user has repeatedly ignored
The bus now keeps a session-scoped declined ledger: a pill the user
watched appear and let die three times stops re-offering for the rest
of the session. Acting on a pill clears its count, so a suggestion
that was taken can come back for the next trigger. In-memory on
purpose — a fresh session is a fresh chance.
2026-08-13 02:17:42 -05:00
Carl Taylor 654435210c feat(cron): surface model drift impact in Desktop 2026-08-12 23:47:22 -07:00
hermes-seaeye[bot] 1ef0a366da
fmt(js): `npm run fix` on merge (#85098)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-13 06:44:04 +00:00
Teknium 89a84e1ae6
feat: profiles.list/profiles.create ws RPC + plugin session-navigation doors (#85093)
Desktop plugins reach the backend exclusively through the generic ws
JSON-RPC door (host.request), but profile enumeration/creation only
existed on the dashboard REST router, which plugins cannot reach — so
anything 'one chat per agent profile'-shaped (bot rosters, profile
pickers, team panes) was impossible to build as a plugin.

- tui_gateway/methods_profiles.py: new @method handlers
  * profiles.list — profiles + optional last_session preview per profile
    (mirrors session.list's kanban/tool deny-list; best-effort per-profile
    state.db probe degrades to null instead of failing the call)
  * profiles.create — ws twin of POST /api/profiles (clone_from/clone_all/
    no_skills/description), plus optional SOUL.md content and a best-effort
    model+provider pin; mirrors the CLI flow (seed skills, safe alias)
  Both run on the RPC pool, not the WS reader thread (list_profiles walks
  skill trees; create copies bundles).
- SDK: host.openSession(id, { profile, intent }) — open a stored session
  the way core surfaces do, soft-swapping to the owning profile's backend
  first (ensureGatewayProfile), and host.newChat(profile) — fresh draft in
  a named profile (same door as the sidebar's per-profile '+').
- Docs: desktop-plugin-sdk.md gains both surfaces.

First consumer: a Grok Bot-style 'Bots' roster plugin (one persistent
chat per agent profile with a New Agent dialog) built on exactly these
four doors.
2026-08-12 23:33:58 -07:00
Brooklyn Nicholson 91a30705eb refactor(desktop): generalize the composer suggestion pills into a provider bus
The pill strip from the inline-MCP work is worth more than one source, so
the MCP-specific store splits into two layers with the same UX contract
(session-scoped, capped, self-limiting, one-click with narrated
idle→working→done):

- store/composer-suggestions.ts — the bus. Draft providers register into
  the existing debounced sampler; event providers push/withdraw directly.
  Offerings merge (event before draft), dedupe by provider-namespaced key,
  and keep reference identity on no-ops.
- store/suggestion-providers/mcp.ts — the founding provider, behavior
  unchanged: directory keyword/host matching, configured-server exclusion,
  one-click connect with OAuth cancel + config rollback.
- composer/suggestion-pills.tsx — the generic strip; phases and cancel
  live here, action/rollback/toasts stay with the provider's invoke.

No new pills yet — this is the seam for them.
2026-08-13 01:22:35 -05:00
Brooklyn Nicholson fd6af8f832 feat(desktop): render the clarify (Recommended) label in tertiary text
The card reads the labelled choices off the gateway request rather than
the raw tool args -- the backend applies the label there, and the card
only mounts once the request exists, so the args are a hydration-race
fallback. RECOMMENDED_LABEL and bareChoice live in the clarify store so
the component and the choice-length guard share one definition; without
the guard a long option could be dropped for length the label added.
2026-08-13 01:19:42 -05:00
Brooklyn Nicholson 6ef0fc4f62 fix(desktop): cancel MCP OAuth flows server-side so a retry doesn't 409 2026-08-13 01:06:51 -05:00
Brooklyn Nicholson 3efce9b98c feat(desktop): suggest MCP servers from the composer draft as brand pills
A renderer-local directory of official hosted MCP remotes (URL-only,
vendor-documented endpoints — deliberately not the reviewed install
catalog) powers keyword and pasted-link suggestions: typing jira or
pasting a *.atlassian.net URL floats an 'Add Atlassian' pill in the
composer's micro-action strip. Matching is whole-word/phrase (unicode
boundaries) plus strict host-suffix on links, host hits outrank
keywords, capped at two, debounced 600ms, and excludes servers already
in mcp_servers. Pills are session-scoped like the micro-action badges
and self-limiting rather than dismissible — they exist only while a
trigger is in the draft. A click drafts the setup request; the agent's
setup_mcp card carries the consent. Brand glyphs extracted from the
mcp-tab into lib/mcp-brands (shared, monochrome marks follow the theme
so GitHub/Notion/Vercel survive dark mode).
2026-08-13 01:06:51 -05:00
Brooklyn Nicholson 6cd4793081 feat(desktop): render setup_mcp as an interactive consent card in the transcript
The card follows the approval bar's consent vocabulary (primary-tinted
action + ghost decline, ⌘⏎/Esc with clarify's focus-stand-down rule) on
clarify's widget shell. Install prefers the reviewed catalog entry (env
prompts inline, background installs polled to completion) and falls back
to the desktop suggestion directory via the validated add-server POST +
OAuth; success reloads live MCP tools before unblocking the agent so it
resumes with the tools it was just promised. Esc stays live mid-flight
as cancel — the abandoned flow aborts at its next poll and a post-write
cancel rolls the config entry back. Typing while the card is pending
declines it and sends normally (skipClarifyRequest's pattern), and the
request/tool.start rows merge on the server arg so reconnects can't
double-render the card.
2026-08-13 01:06:51 -05:00
Brooklyn Nicholson a9eb7e09d9 feat(desktop): marquee clipped inline row titles on hover
The one-line session row gets the exact treatment the inbox card's title
already has: hovering a truncated title glides the clipped tail into view —
one direction at constant speed, a short hold at each end, then a snap back.
Same armMarquee/disarmMarquee handlers, same CSS, so overflow is measured on
pointerenter, short titles never move, hover state lives in DOM attributes
(no re-render of the memoized row), and reduced motion disables it.
2026-08-13 00:55:33 -05:00
张豪杰 1535c114c9 test(desktop): cover connection.json owner-only mode end to end
The helpers were tested; nothing proved main.ts called them. Reverting both
call sites and both imports in readDesktopConnectionConfig /
writeDesktopConnectionConfig left the whole suite green (947 passed / 2
skipped, tsc 0, eslint clean, e2e 1 passed 1 skipped) while connection.json
went back to 0644 — the user-visible fix this PR promises was untested.

The e2e spec could not catch it by construction: it asserts the ENCRYPTION
contract with a raw-bytes scan, and safeStorage keeps the token opaque
regardless of the file's mode, so a 0644 file passes that scan every time.
There was no mode assertion anywhere in e2e/.

Adds the missing third contract — unreadable by other local accounts — on all
three paths that can produce the file:

- write: assert the mode of the artifact test 1 already proves the app wrote.
- read, valid file: seed the app's own encrypted connection.json back to 0644
  and assert launch tightens it. Scoped to the MODE only, so it is independent
  of the still-deferred plaintext migration — the fixture's token is already
  ciphertext, so nothing re-encrypts, no #62319 opt-in marker is involved, and
  no rotation guidance is owed.
- read, corrupt file: a truncated file still holds the token bytes and throws
  into the swallowing catch, so it would be the one file never tightened. This
  is the only test that distinguishes the chmod's placement relative to the
  parse.

Also moves the tighten above JSON.parse for exactly that reason, and pins the
cache invariant the placement depends on: the tighten must be a chmod, not a
rewrite, because it sits inside the function whose cache keys on mtimeMs.

Asserted as `mode & 0o077 === 0` rather than `=== 0o600` to avoid a
change-detector, and skipped on win32, where chmod maps to the read-only bit
and the fix deliberately no-ops (ACLs are PR #77527).

Every assertion was mutation-tested: reverting the full wiring fails all three;
reverting only the write path fails only the write test; deleting only the
tighten-on-read fails only the two read tests; moving the tighten below the
parse fails only the corrupt test; making the tighten a rewrite instead of a
chmod fails the mtime assertions. Bundle greps confirmed each mutation reached
dist/electron-main.mjs before the run.

(cherry picked from commit 99cfc16e7c)
2026-08-12 22:38:17 -07:00
张豪杰 7e151bd9d3 fix(desktop): create connection.json owner-only
`connection.json` under the desktop app's Electron `userData` was written with no
file mode, so it landed at the `0644` umask default — while its two
credential-bearing neighbours in the same directory, `desktop-installation.json`
and `native-oauth-tokens.json`, were already `0600`. That file holds the
safeStorage-encrypted gateway token plus the fields that are NOT encrypted: the
gateway URL and the SSH host, user, and key path.

- Route the single write choke point through a helper that creates the file
  owner-only and atomically.
- Tighten an already-existing `0644` file once per launch on the read path, so
  installs that already have one do not stay world-readable until the next save.
- Refuse to act on a path that is a symlink or not owned by the current user,
  matching the guards `desktop-installation.ts` already applies to its sibling.

The symlink guard alone turned out to be insufficient, and that is worth
recording: `writeSecretFileAtomic` tightens its *temp* path, so a symlink planted
at `connection.json.tmp` meant `writeFileSync` followed it, the guard correctly
bailed, and `renameSync` then moved the link onto `connection.json` permanently.
Measured, guard-only vs. as-landed:

    guards only          token leaked: true    config is a symlink: true   755
    guards + temp unlink token leaked: false   config is a symlink: false  600

So the temp path is unlinked before the write.

Issue #77486's headline claim — that a dashboard session token is persisted in
plaintext — does not hold against main. The token has been safeStorage-encrypted
since the desktop app reached mainline in 51c68d4ab, and `encryptDesktopSecret`
aborts with an actionable message rather than degrading to plaintext when
safeStorage is unavailable. The `{ encoding: 'plain', value }` literal does exist
at main.ts:7084, but only on the `persistToken: false` branch, whose sole caller
is the connection-test handler, which never writes. So no mainline path *writes*
a plaintext token. The commits that did contain a plaintext-writing fallback
(d3d177283, d208f2c2c) are not ancestors of main — they live only on
upstream/bb/gui-* and the desktop-pr20059-installers pre-release tag.

At-rest migration of legacy non-safeStorage payloads is deliberately NOT included.
An earlier revision of this branch implemented it and it was removed after review
reproduced two token-loss paths: it force-converts the opt-in plaintext choice
PR #62319 adds (silently reverting the user's decision, then destroying the token
on the next launch without the `--password-store=basic` flag), and it converts a
portable credential into a keychain-bound one with no consent — destroying the
only recoverable copy while not remediating the real exposure, since every
existing backup still holds the plaintext and the true remedy is rotation. It also
persisted raw `parsed`, bypassing `sanitizeConnectionProfiles`. A comment at the
read path records the three preconditions any future attempt needs.

`decryptDesktopSecret`'s non-safeStorage read fallback is untouched — it is what
lets a pre-release or hand-edited config work at all.

Windows still inherits the userData directory ACL rather than an explicit
owner-only one; mode bits are advisory there, so that half is deferred to
PR #77527 rather than growing a second ACL implementation here.

e2e: `at-rest-connection-token.spec.ts` asserts the at-rest contract
implementation-independently — the token's plaintext value (and its base64 form)
must not appear in a raw-bytes scan of any file under userData or HERMES_HOME,
AND the app must still put the exact original token on the wire after a restart,
so a fix that simply drops the token cannot pass. Proven non-vacuous by mutation:
writing `{ encoding: 'plain', value }` still fails the scan while the
file-exists and gateway-URL guards pass. The migration case is a documented
`test.fixme` naming its three blockers.

Electron project 928 -> 924 tests (-9 migration, +5 new guard and
mechanism-isolation). Two of those five exist because reverting either owner-only
mechanism alone initially scored zero failures — they were masking each other, so
either could have been deleted green.

(cherry picked from commit 6e01add657)
2026-08-12 22:38:17 -07:00
brooklyn! 7626105380
Merge pull request #84943 from NousResearch/bb/review-summary-chrome
Self-improvement review row wears the same gold→purple chrome as a memory write
2026-08-13 00:18:44 -05:00
Brooklyn Nicholson 5af2c2ff52 fix(desktop): messages typed during approval/sudo/secret prompts run as the next turn
Typing while the turn was parked on a blocking prompt routed the text
through steer (session.redirect), which sat undelivered behind the
blocked tool batch — nothing rendered, and stopping the turn to force it
through resolved the prompt to empty and ended the turn as the literal
"Operation interrupted." row, eating the message.

Clarify already had a carve-out (typing skips the question and steers)
because a real message IS an answer to a clarify. Approval/sudo/secret
have no such answer path, so the busy submit now queues the words as the
next turn instead: the prompt stays answerable, the queue drains on
settle, and the busy button advertises queue rather than steer while one
is pending. Slash commands still execute inline, and another session's
prompt never affects this one.
2026-08-13 00:13:36 -05:00
hermes-seaeye[bot] 08606fc231
fmt(js): `npm run fix` on merge (#85024)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-13 04:08:31 +00:00
Brooklyn Nicholson 4d9202b963 feat(desktop): right-click the shell chrome for window-level actions
Right-clicking anywhere the app owns no menu of its own — the titlebar
gutter, an empty pane body, the sidebar background — did nothing at all.
Electron's native handler bails on non-editable, non-selected content by
design, so those surfaces had no menu to fall back to.

Wrap the shell in a fallback context menu carrying the verbs that belong
to the window rather than to a row: new session, new window, command
palette, toggle the status bar, settings, update Hermes. Every row reuses
the store action and the copy its Cmd+K twin already uses, so the two
can't drift.

A guard on an inner element keeps it a fallback: a right-click that lands
inside a surface with its own context menu, on an editable, or on a live
selection stops propagating before Radix's trigger sees it, leaving that
surface's menu — or Electron's native edit menu — in charge.
2026-08-12 22:55:37 -05:00