Commit Graph

23497 Commits

Author SHA1 Message Date
Andrew Walker e3c4bfe9ab fix(bot-mode): show profile-owned cronjobs 2026-08-17 16:34:02 -07:00
Teknium aa8ceed4b6 fix(memory): keep a stale holder's late close() from evicting a fresh registry entry
Follow-up to the #88347 salvage: after release_all_under() force-closes a
profile's shared connection, a store re-created on the same path registers
a fresh entry under the same key. A stale holder that later calls close()
would pop that fresh entry (its refs were transferred nowhere), letting a
third store open a second connection to the same database — exactly the
multi-writer contention the shared registry exists to prevent. close()
now evicts the registry entry only when it is still its own.
2026-08-17 16:33:52 -07:00
liuhao1024 4f354c27b7 fix(profiles): release memory-store handles before rmtree on profile delete
The desktop's main serve process opens memory_store.db for every known
profile and nothing closed those connections before delete_profile's
rmtree — on Windows the open SQLite handles make the removal fail with
WinError 32 for both the CLI and the DELETE /api/profiles/<name> route
(#88347). POSIX unlinking of open files hid the same leak.

MemoryStore.close() is refcount-driven, so a live holder keeps the
handle forever; add MemoryStore.release_all_under(directory) to
force-close every shared connection under a directory, and call it in
delete_profile after stopping the profile backends. Inside serve the
handles live in that very process and get released; from the CLI it is
a no-op.

Fixes #88347
2026-08-17 16:33:52 -07:00
Teknium 399fdeae8b fix(bot-mode): plugin listener leaks, jsx key prop, OAuth poll overwrite
Three bugs in apps/desktop/src/plugins/hermes-bots/plugin.js:

1. register() subscribed host.state.profile / host.state.gateway listeners
   without capturing the unbind functions, so a plugin disable -> re-enable
   cycle stacked a duplicate listener per cycle that kept firing until app
   quit (same survives-disable class as the face clock before its
   onDispose hook). The unbinds are now captured and released via
   ctx.onDispose.

2. CreateRoutineDialog passed `key: createTarget` INSIDE the jsx() props
   object. The react/jsx-runtime silently ignores a `key` prop there (key
   is the third jsx() argument), so switching the routine owner never
   remounted the dialog and it kept stale per-bot form state. Moved the
   key to the third argument; the source-shape test now pins the correct
   shape and rejects the prop form.

3. beginOAuth() overwrote pollRef.current without clearing an existing
   interval, so a retry / double-click while a poll was live orphaned a
   2s poller that ran until unmount and could flip the phase from a stale
   OAuth session. It now clears any live poll before starting a new one.
2026-08-17 16:33:42 -07:00
Teknium aa2622128f fix(hermes-bots): show profile pics in group chat rooms
Community report: group chats showed neither profile pictures nor proper
names for members (names fixed in #88721; this adds the avatars).

- Every bot message in a group room now carries the speaker's avatar,
  resolved through the same botAppearance pipeline as the roster: custom
  uploaded/generated/pet images honored, backfilled PNGs dropped so the
  animated math face renders, deterministic shape+color for bots with no
  customization (including remote speakers with no local meta).
- The room header shows the member roster as overlapping faces (capped
  at 6) with a display-name tooltip, alongside the existing count.
2026-08-17 16:29:45 -07:00
Teknium 9b112e5736 refactor(desktop): extract pool teardown into pool-stop.ts with behavior tests
Follow-ups on the salvaged bounded-teardown fix:

- Replace the source-grep regression test (backend-lifecycle.test.ts asserted
  main.ts source text) with behavior-contract tests against the extracted
  createPoolStopper(): handle retention until bounded exit, stop dedup,
  stopAll completeness, and the respawn-awaits-dying-backend gate.
- Gate the registry-local spawn path on the in-flight stop too (sibling site
  the original PR predates).
- Route teardownPoolBackendAndWait through the shared stopper so profile
  delete/rename teardown covers both local pool keys with the same bounded
  semantics.
2026-08-17 16:26:02 -07:00
giga corp c07171209a fix(desktop): await pooled backend teardown 2026-08-17 16:26:02 -07:00
Teknium c6a0789bc5 fix(desktop): retire old-name renderer sockets and widen the deletion gate for renames
Follow-ups on top of the salvaged rename lifecycle:

- Retire both local renderer socket scopes for the OLD profile name before
  the rename PATCH (rename-profile-dialog), mirroring the delete-path fix
  from #88638 — a retained socket otherwise treats the rename's backend
  teardown as a transient drop and its reconnect respawns the old-name
  backend, whose ensure_hermes_home() recreates the directory the rename
  just moved (#45474).
- Hold the profile deletion gate for rename requests too, so a concurrent
  renderer reconnect entering ensureBackend() mid-rename cannot respawn the
  old-name backend.
- Regression tests for retire-before-rename ordering and the
  validation-rejected path.
2026-08-17 16:15:22 -07:00
wangdong 69abe41f36 fix(desktop): re-home backend after profile rename 2026-08-17 16:15:22 -07:00
Teknium 4b7eb02907 fix(hermes-bots): group chats never surface @default — Hermes keeps its name
Community report: in a Bot Mode group chat with the primary agent, Hermes
lost its name and rendered as @default in the room.

- Workspace speaker labels now render the roster displayName (default →
  Hermes, titles respected) instead of the raw @profile id. Clicking a
  speaker reveals the full disambiguated form — Display-gatewayname
  (@handle) — so same-named agents on two connections stay tellable
  apart on demand; naturally they just show their display name.
- Room transcripts fed to members render the default profile as Hermes
  (new groupSpeakerLabel helper).
- The per-turn prompt addresses members by @handle (@hermes for the
  primary profile) instead of @default, and parseGroupChatMentions now
  resolves @hermes back to the default member via botHandle.
2026-08-17 16:14:53 -07:00
Teknium 6229683b62 feat(cli): hermes peer — bot-to-bot DMs across machines and gateways
Bots could message teammates on their own machine (hermes -p <bot> chat) and
the desktop could relay user mentions over Connections, but a bot had NO
transport to a bot on another gateway. This adds one, with zero new server
surface: the peer's existing api_server platform is the wire.

- hermes_cli/subcommands/peer.py: `hermes peer add/list/remove/dm`.
  `dm <peer>[/<agent>]` resolves the remote agent's canonical "Bot Chat"
  (list by title, create when missing), runs one synchronous agent turn via
  POST /api/sessions/{id}/chat, and prints the reply on stdout — the exact
  cross-machine twin of the local bot-messaging command, so the Bot Mode
  protocol composes over it unchanged. Named profiles route via the peer's
  /p/<profile>/ multiplex mirror. Peer URLs live in config.yaml
  (`bot_peers`); the peer's API_SERVER_KEY is a credential and lives in
  ~/.hermes/.env as HERMES_PEER_<NAME>_KEY.
- hermes_cli/main.py: parser wiring + fast-path/session-flag command sets.
- tools/bot_mode_probe.py: when peers are registered, the injected Bot Chat
  messaging protocol gains a cross-machine paragraph (peer roster +
  `hermes peer dm` pattern) so agents discover remote teammates on their
  own; peers join the capability fingerprint so registering/removing one
  refreshes eternal Bot Chat prompts on the next message (loud, one-time,
  user-initiated — no per-turn cache drift).
- Docs: Bot Mode guide (bot-initiated DMs across machines) + cli-commands
  reference (`hermes peer` section + summary row).

Tests: tests/hermes_cli/test_peer_cmd.py (target parsing, /p/ scoping,
registry round-trip in isolated config, real-loopback-HTTP dm flow incl.
Bot Chat create-vs-reuse and bearer auth), bot_mode_probe peer-paragraph +
epoch tests. E2E: real `python -m hermes_cli.main peer ...` against a live
fake peer over HTTP with isolated HERMES_HOME (config/.env persistence,
bare + /p/<profile> routing, stdin, --json). 23 passed; ruff clean.
2026-08-17 16:13:30 -07:00
Teknium 66221397a1 fix(bot-mode): always hide Bot Mode sessions from the global Sessions sidebar
Bot Mode's group chats spawned one per-member session per room, and those
"Group: ..." rows (plus canonical Bot Chats when the old eye-toggle pref was
off) flooded the global Sessions sidebar — a 6-bot room dumped six identical
rows into recents (reported with screenshot, Aug 17).

Plugin (apps/desktop/src/plugins/hermes-bots/plugin.js):
- session.create now passes hidden:true UNCONDITIONALLY for both canonical
  Bot Chats and group-room member sessions; the $hideBotChats pref, its eye
  toggle, and its storage hydrate are removed (Bot Mode sessions are plumbing
  or plugin-owned forever-chats, never scratch conversations).
- hideOwnedBotSessions(): idempotent reconciliation sweep over every owned
  session id (bot meta canonical chats + each room's member sessions) via
  session.set_hidden, run on plugin load and on each gateway reconnect, so
  rows born visible under the old pref get cleaned up.
- The Bots session browser and canonical-chat recovery scan pass
  include_hidden:true so they still see the rows they own.

Gateway (tui_gateway/methods_session.py):
- session.list honors an include_hidden param (default off — the resume
  picker and all global callers keep dropping hidden rows).
- session.set_hidden gains a durable fallback: when no LIVE runtime session
  matches, resolve the stored session id in the target profile's state.db
  (via resolve_session_id) and flip the flag there. The sweep holds stored
  ids for chats that aren't live; the old live-only lookup 4001'd them.

Validated E2E with real imports against a temp HERMES_HOME: born-hidden row
(hidden=1), profile-scoped session.list default vs include_hidden (0 vs 1),
and stored-id sweep on a non-live legacy row (hidden=1). Plugin suite
167/167; new RPC regression tests in tests/tui_gateway/test_session_hidden_rpc.py.
2026-08-17 16:02:42 -07:00
hermes-seaeye[bot] bb92473074 fmt(js): npm run fix on merge (#88720)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 22:59:42 +00:00
embwl0x 55e34fb7d0 fix(desktop): avoid local profile REST backend spawns 2026-08-17 15:51:02 -07:00
adybag14-cyber 51de5c91b5 fix(termux): preserve Nix update guidance 2026-08-17 15:47:17 -07:00
adybag14-cyber 9e81a102df chore: map contributor email 2026-08-17 15:47:17 -07:00
adybag14-cyber d685ea4df5 docs(termux): document community pkg distribution 2026-08-17 15:47:17 -07:00
adybag14-cyber 26466d0594 feat(termux): support apt-managed Hermes installs 2026-08-17 15:47:17 -07:00
Gille 976183f536 fix(desktop): stop deleted profiles reconnecting 2026-08-17 15:46:44 -07:00
Gille 68a713385e fix(gateway): preserve configured context window 2026-08-17 15:46:23 -07:00
Teknium 2c8a2b65aa docs(sdk): document profiles.list preferred_session_ids lookup
Covers the precise pinned-session resolver added in PR #88690: request
param shape, the preferred_session response field, hidden-row and
compression-lineage resolution, and older-gateway behavior.
2026-08-17 15:35:36 -07:00
Teknium 21aa327c69 style: ruff lint + format on the eval scripts (encoding args, formatting) 2026-08-17 15:29:16 -07:00
Teknium 5033aedaa3 feat(evals): add the Browser Use mode A/B benchmark from PR #81958
Reconstructs the 204-run benchmark battery behind #81958 (Browser Use CLI
3.0 mode) as a rerunnable eval under evals/browser-use/, following the
toolperf_abeval / evals-compaction pattern.

- tasks/easy.json + tasks/hard.json: the oracle-checked toscrape task
  batteries (5 easy, 6 hard) exactly as run for the PR
- single_run.py: one cell = task x arm (base | pr | prns) x model x rep;
  throwaway HERMES_HOME, web-fetch creds stripped, arms pinned to separate
  trees via BUBENCH_BASE_TREE / BUBENCH_PR_TREE
- orchestrate.py: resume-safe local-CDP battery driver
- orchestrate_cloud.py: backend matrix (nous-cloud via the browser_use
  provider plugin, browserbase via REST) with per-cell session lifecycle
- report.py: scorecard aggregation with vs-base token deltas
- README.md: design, run instructions, and the recovered Aug 8-10 2026
  baseline scorecards (hard battery, backend matrix, easy round 1,
  digest ablation)

The original /tmp/bu-bench workspace was lost to a tmpfs reboot; harness
and readouts were recovered verbatim from the benchmark session's tool-call
history in state.db, with hardcoded paths parameterized. Smoke-verified
live: report.py aggregation, and single-cell runs (pr + base arms) against
a real headless Chrome CDP with sonnet-5 driving browser_exec, oracle pass.
2026-08-17 15:29:16 -07:00
Teknium 4151599e81 fix(desktop): cross-machine bot DMs — canonical chat, sender attribution, reply relay
Follow-up to #88664. The remote @mention delivery path had three gaps that
made cross-machine DMs half-work:

1. No reply relay: deliverRemoteRosterMentions submitted the prompt and
   toasted, but never polled — the handoff note promised a relay that never
   came. Now a bounded poll (same shape as a group member turn: new
   assistant message after the baseline, 180s cap) relays the recipient's
   reply as a notification, or says it's still pending.
2. No sender attribution: the raw user text was submitted, so the
   recipient's messaging protocol never recognized an agent-to-agent
   message. Deliveries now carry the standard
   "Message from 🤖 <sender> (@handle):" prefix.
3. Duplicate Bot Chats: every mention minted a fresh "Bot Chat" session.
   ensureRemoteCanonicalChat now resolves the recipient's pinned canonical
   chat from its profile ui_meta, falls back to resume-by-title, and only
   creates when neither exists — mirroring ensureGroupChatSession.

Tests: remote-dm-delivery.test.mjs (pin-resume without create, attribution
prefix + reply relay via vm-run behavior tests, source contract for the
bounded poll). Plugin suite 187/187.
2026-08-17 15:28:43 -07:00
Teknium d0b02efa9d fix(desktop): don't treat a mid-switch 404 as session deletion (#88540)
A resume racing a profile/connection swap can 404 on a backend that
does not own the session; the terminal-failure branch then dropped the
window to the blank new-chat route while the target session was alive.

goneSessionVerdict() now gates the draft fallback: a session created
this run, still listed on some profile, or looked up while a gateway
swap is in flight arms the bounded auto-retry latch instead of
discarding the route. Draft remains the calm-conditions path for
verifiably dead ids.
2026-08-17 15:27:00 -07:00
NealZhouPanda 8484786832 fix(desktop): align BOTS roster preview with the session its click opens
The BOTS sidebar previewed each profile's most recently active session
(last_session) but clicking the row opened the pinned canonical chat —
two different session identities, so the preview described one
conversation and the click landed in another.

- profiles.list gains an optional preferred_session_ids param
  ({profile: session_id}): an exact, existence-checked per-profile
  lookup that resolves hidden rows and compression lineages to the
  live tip (the same resolver session.resume uses) and returns a
  preferred_session summary alongside the unchanged last_session.
- The hermes-bots plugin sends its canonical-chat pins with each
  roster poll and previews preferred_session ?? last_session.
- openBotCanonicalChat verifies pins through the precise resolver
  instead of a paginated, hidden-excluding session.list window that
  misjudged real hidden pins as gone; transient lookup failures no
  longer clear the pin or mint a replacement chat.
- Grandfathering: a bot with history but no pin adopts the previewed
  session on first open instead of minting a new empty chat — the
  behavior the design comment already promised.

Closes #88200
2026-08-17 15:26:48 -07:00
Adolanium 69a62ce2c8 fix(desktop): keep the Bot Chat pin
A failed intro used to clear the pin even though the chat was already made, so the next click opened a second one. A missing pin grabbed the newest session, even when a real Bot Chat was in the list.

Failed intros now keep the pin. A missing pin looks for a session titled Bot Chat. If that is not there, we try the stored pin instead of the newest row.

Ported from NousResearch/Hermes-Bot-Mode#59 after Bot Mode moved in-tree.
2026-08-17 15:26:48 -07:00
hermes-seaeye[bot] dc2e9dde9a fmt(js): npm run fix on merge (#88671)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 21:39:36 +00:00
Teknium bf53915331 docs: Bot Mode guide — Create-on picker, cross-machine mentions + group chats
Community asks (Discord, Aug 17): unclear what happens across cloud vs
desktop, whether every bot replies in group chats, and how to persist
connections to multiple gateways.

Extends the Bot Mode user-guide page (landed on main today) with the new
cross-connection features:

- "Create on" picker: creating an agent on another registered machine, with
  the remote-target caveats (clone source, staged capability checklists,
  draft discard).
- Group chats: explicit "not every bot replies" explanation of the
  round-robin/pass model, and rooms spanning machines with device badges.
- @mentions across machines via the Connections registry (no gateway switch).
- Bots-across-machines section: persistent SSH inventory, last-known rows,
  and the stay-in-your-chat interaction model; cloud+desktop recipe.
- desktop.md Bot Mode section links to the full guide; multi-connection
  page's Bot Mode reference points at the docs page instead of the old
  standalone repo.
2026-08-17 14:32:52 -07:00
Teknium 62f376279b feat(desktop): cross-connection Bot Mode — Create-on picker + multi-machine group chats
Builds on the salvaged #88598 multi-source roster work:

- New Agent "Create on" picker (multi-connection registries only): the
  profiles.create/configure/describe/mcp.catalog calls route to the picked
  connection's backend via host.requestProfile route descriptors — the
  window's active gateway never switches. Remote-target drafts discard via
  the remote CLI; appearance/title write into the remote profile's ui_meta
  and asset store; the taken-name check is scoped to the target machine's
  roster; the live SkillsView Capabilities tab (active-gateway-bound) falls
  back to the staged checklists for remote targets.

- Group chats can seat bots from other registered connections: member turns
  (session.create/resume, prompt.submit, reply polling) route to each
  member's own source through the new requestForBot helper. Remote member
  descriptors persist on the room record (bot-meta is active-gateway-scoped
  by design); watermarks/sessions key by source-qualified member keys so
  same-named agents on two machines never share state; @name-device handles
  resolve in room mentions; room lines and turn prompts badge cross-machine
  speakers with their device.

- pidIsOurDashboard: a dead remote PID (ps exits non-zero) now reads as
  FOREIGN instead of throwing "Could not verify SSH backend process
  ownership" — the misleading error from #88625's secondary report.

Tests: cross-connection-bots.test.mjs (route descriptors, requestForBot
routing, member keys, disambiguated mentions, device badges, source
contracts); plugin suite 180/180; electron vitest 220/220.
2026-08-17 14:32:52 -07:00
Teknium e3c954447c chore: map contributor email for @AL-ZiLLA 2026-08-17 14:32:52 -07:00
Al Zilla f89f884a5d fix(desktop): mention Connections bots from this local chat
Stay on the default gateway. Bot Mode still lists every Connections
agent. Clicking a remote row no longer hops the window onto SSH —
@dixie / @bob-spark in this chat resolve against the roster and
Desktop delivers in the background via requestProfile.
2026-08-17 14:32:52 -07:00
Al Zilla 30299efa38 fix(desktop): open SSH default bots on the remote root profile
Clicking Mac Mini / Spark (the device default row) passed the desktop
pool key as the remote Hermes profile. That profile does not exist, so
the chat never opened. Named profiles (bob, dixie) already sent a real
name and worked.

Also refuse to fall back to this-device's default chat pin when the
remote source did not actually become active.
2026-08-17 14:32:52 -07:00
Al Zilla c2c3058eca fix(desktop): never spawn SSH dashboards just to list Bot Mode agents
A leftover sshConnections key made roster polling call
ensureRegistryBackend for every SSH source every ~5s. That spawned
remote dashboards, the mux died, and the renderer hit hermes:api
ECONNRESET / liveness-probe drops.

SSH inventory stays on the cached ls path only. Clicking a bot still
dials that one source.
2026-08-17 14:32:52 -07:00
Al Zilla 4afe08f494 fix(desktop): retry failed SSH Bot Mode inventory after cooldown
A first inventory miss used to stick forever as a seeded default until
the user hit Test. Retry after 60s; a successful cache still never
re-probes, and Test still forces an immediate refresh.
2026-08-17 14:32:52 -07:00
Al Zilla 89877d10a5 fix(desktop): drop Bot Mode rows for removed SSH connections
Remembered remotes were restored whenever the union omitted their
connection id, so a deleted registry source could keep resurrecting
until remount. Only restore rows that still belong to a registered
source.
2026-08-17 14:32:52 -07:00
Al Zilla 4068162118 fix(desktop): keep SSH Bot Mode agents visible across local clicks
Clicking the local agent left connectionId null, so the roster treated
the registry primary (often an SSH box) as active and dropped its
profiles while inventing a "This device" shadow of default.

Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles
instead of requiring the window to switch onto that machine. Hostile
HERMES_HOME values are rejected before the listing command runs.
2026-08-17 14:32:52 -07:00
Teknium bd9c7d87f0 feat(desktop/bots): add disband (delete) for group chats
Group chats could be created but never deleted — the only way out was
manually ungrouping every member, which still left the shared room log
in plugin storage forever.

The group-chat workspace header now has a trash button behind a
ConfirmDialog. Disband is soft: it clears every member's group
assignment (syncs cross-machine via ui_meta), drops the room log from
the atom and the persisted group-chats map, clears the needs-you badge,
and closes the room view. The members' per-group gateway sessions
("Group: <name>") are intentionally kept and remain reachable from each
bot's session browser. A room with a drive still in flight leaves a
runtime-only epoch-bumped tombstone so the round-robin loop bails at its
next member boundary; the tombstone is never persisted.
2026-08-17 14:32:11 -07:00
Teknium 39c0bc80b7 feat(desktop): link the installer from the app-build-out-of-sync warning
The skew banner told users to run the in-app update, but on machines where
the local desktop pack is the broken step (e.g. the get-windows win32
binding staging failure in #88251), rebuilding in place cannot succeed.
Reinstalling from the packaged installer sidesteps the local build
entirely, so offer it as the escape hatch:

- Settings > About skew banner gains a "Get the installer" button opening
  https://hermes-agent.nousresearch.com/ via openExternal
- banner copy now mentions reinstalling when the update doesn't clear the
  warning
- all 5 locales updated (new bundleOutOfSyncAction key)
2026-08-17 14:31:09 -07:00
hermes-seaeye[bot] cf7b3d0c90 fmt(js): npm run fix on merge (#88652)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 21:15:38 +00:00
witcheer 851a30d0ab docs: add a Bot Mode user-guide page
Bot Mode ships built into the desktop app (default on) but only had a
short section in desktop.md. This adds a dedicated user-guide page
covering the Bots roster, creating and editing Bots, avatars, routines,
group chats, bot-to-bot messaging (agent.bot_mode_protocol), the
multi-connection roster, and CLI parity.
2026-08-17 14:08:38 -07:00
Brooklyn Nicholson 70f9560b29 test: accept keyword args in the prompt-submit fallback stub
_run_prompt_submit grew a display_kind keyword; the fail-open test's
monkeypatched stub pinned the old 4-arg shape and broke on the call.
2026-08-17 16:08:18 -05:00
Brooklyn Nicholson 3ead0f8dc1 feat(desktop): widget clicks reach the agent as hidden user turns — the widget updating IS the response
An inline ::preview widget could render and be clicked, but the click went
nowhere: the sandbox has no channel to the agent, so an interactive chart
was a dead end. Now the frame injects a second script beside the measurer
that gives the page one voice:

  window.hermes.send('get-price eth')
  <button data-hermes-send="get-price eth">ETH</button>  (zero-script form)

The prompt rides postMessage up tagged with the mount token, then goes
through the composer's own send path (requestComposerSubmit -> prompt.submit)
flagged display_kind=hidden — the same row-typing auto-continue and internal
notifications already use. The agent wakes and takes a real turn; the
durable row persists (context, resume, DB audit); but NO bubble renders,
live or on reload. The user clicks ETH and the chart just changes — the
off-screen loop is click -> hidden turn -> agent rewrites the widget file ->
frame hot-swaps.

Trust boundary matches size reports and is tighter where it matters: mount
token required (frames can't forge each other's intents), string-only,
trimmed, capped at 500 chars, throttled to one intent per second per frame.
The gateway whitelists display_kind to "hidden" — the RPC can't mint
arbitrary row types — and the flag threads through both turn paths (inline
and compute-host isolation) so isolated sessions don't resurrect bubbles on
resume.

The desktop platform hint teaches the model to wire interactive widgets
with data-hermes-send and to answer clicks by updating the widget's file
rather than with prose; the SDK doc documents the contract.
2026-08-17 16:08:18 -05:00
Teknium 6e22d26583 feat: project-skill quarantine + non-interactive trust inheritance
Completes the project-local skills epic's remaining skill items (#48974,
#48975) on top of the discovery/trust work in #88566.

Quarantine (#48974): trust is a repo-level decision made once, but repo
skill content changes with every pull — the hub install path scans, a
checkout didn't. Every project SKILL.md dir now runs through the same
skills_guard scanner as hub installs (content-hash cached under
~/.hermes/cache/project_skill_scans/, never inside the repo). Verdict
'dangerous' quarantines the skill: excluded from the index, skills_list,
and slash commands via the single iteration chokepoint
iter_project_skill_files(), and skill_view refuses by name with an
explanatory error. Scanner failure fails closed. Verified against a real
injection fixture (6 findings: prompt_injection_ignore, deception_hide,
invisible_unicode, credential exfil patterns).

Non-interactive inheritance (#48975): find_project_root() now resolves
from TERMINAL_CWD (the per-surface workdir cron jobs and the terminal
tool already use) before falling back to process cwd. Cron/API/ACP
surfaces inherit a prior interactive trust decision by project identity:
job workdir inside a trusted repo => project skills load; untrusted or
no workdir => nothing loads; no surface ever prompts.

Tests: +10 cases in tests/agent/test_project_skills.py (real malicious
fixture, fail-closed, rescan-on-change, cache location, TERMINAL_CWD
inheritance matrix). Docs: quarantine + non-interactive sections in
skills.md.
2026-08-17 14:06:16 -07:00
Teknium a13be69572 feat(desktop): warn in About when the app build is out of sync with the runtime
hermes update moves the source tree, but the desktop UI (including bundled
plugins like Bot Mode) is compiled into the app binary at build time. A
terminal-side update — or an in-app update whose bundle-swap leg failed —
leaves a new runtime under an old renderer: About reports the new Hermes
version while the sidebar is missing that version's desktop features
(the 'no Bots tab after the Bot Mode update' reports).

Detect the skew by comparing the packaged install-stamp commit against the
tree (git rev-list --count <stamp>..HEAD -- apps/desktop) and surface it:

- Settings > About: amber warning banner pointing at the updater
- macOS native About panel: suffix on the version line
- hermes:version IPC gains bundleOutOfSync / bundleCommitsBehind

Fail-quiet by design (no stamp / fallback stamp / git failure = no warning)
so dev runs and non-git builds never see a false 'install is torn' alarm.
All 5 locales covered.
2026-08-17 14:05:09 -07:00
Teknium b779fbf423 feat(desktop): route the bots face clock through the SDK budgeted loop
Exports createBudgetedLoop (+types) through the plugin SDK and migrates the
Bots plugin's face-animation clock onto it. The hand-rolled clock only
checked document.hidden; via the shared loop it now also pauses while the
window is minimized or unfocused, matching every other desktop render loop.

- sdk/index.ts: export createBudgetedLoop/BudgetedLoop/BudgetedLoopOptions
  from @/lib/budgeted-loop with plugin-facing guidance
- hermes-bots/plugin.js: startFaceClock delegates scheduling to the SDK loop
  when present (fps 15, idleWhen = no visible faces); paint body, IO-based
  visibility tracking, and the 1Hz rescan are shared; the hand-rolled rAF
  path remains as a feature-detected fallback for older desktops, per the
  plugin's established SkillsView/McpTab pattern
- tests: new SDK-path case (fps/idleWhen wiring, visibility wake, re-entry
  wake, dispose-on-stop) driven through an injected fake loop; verified to
  fail with the SDK branch removed; existing fallback-path tests unchanged
2026-08-17 13:54:53 -07:00
Brooklyn Nicholson aeabff6aec fix(desktop): satisfy the plugin-render and import-order lint rules
CI's check:lint caught three real issues in the directive surface:

- TranscriptDirectiveLeaf called the contribution's render() inline in JSX
  — the exact pattern no-restricted-syntax bans because the callback's hooks
  land in the host and a plugin reload changes the host hook count
  (React #310). The callback is now memoized and mounted via ContribRender.
- The inline frame mirrored its height state into a ref from the message
  handler (the stale-read pattern no-restricted-syntax flags). Functional
  setState reads current state directly; the shadow refs are gone.
- perfectionist import/export ordering in the frame and the SDK index.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson 20ec564684 docs: align the ::preview description and storage example with shipped behavior
The SDK doc still described the v1 frame (fixed height attribute, rail card
under the frame) — chrome that no longer exists. And plugin_storage's usage
example used `with plugin_db(...)`, which reads as auto-close but sqlite3's
context manager only scopes transactions; the example now closes explicitly.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson a1fea53454 feat(desktop): inline previews read as native widgets — content-sized, theme-bridged, interactive
The first inline frame was a full-width bordered box at a fixed height:
webpage-in-a-rectangle, not a widget. Now the frame disappears into the
message flow:

- Content-driven size. The injected measurer reports height (live) and
  intrinsic width (adopted once, so %-width children can't feedback-loop the
  frame toward zero). A sparkline shrink-wraps and sits flush left like an
  inline image; a full-bleed page measures the whole viewport and stays
  column-wide. The height attribute is now only a starting value.
- Theme bridge. A style prelude injects first with the app's resolved theme
  tokens under stable names (--foreground, --muted-foreground, --accent,
  --border, --card), the app font, zero body margin/padding, and a
  transparent background — reference HTML written against those vars renders
  native in any theme. Page styles override the prelude, so a page that
  brings its own design keeps it.
- No chrome. Border, rounded box, and the rail-opener card under the frame
  are gone; the fallback paths (non-HTML, remote gateway, unreadable file)
  keep the classic card. The wheel gate went with the border — frames size
  to content, so there is nothing to scroll inside, and widgets are fully
  interactive.
- The desktop platform hint now teaches the default: an inline widget is
  transparent, token-colored, flush left, no page chrome — only a standalone
  page brings its own background. "Make me an inline sparkline" gets native
  styling without the user spelling it out.
2026-08-17 15:12:53 -05:00
Brooklyn Nicholson d690e0220e fix(desktop): drop provider-echoed duplicate text so replies don't render twice
Some providers re-send the previous assistant text verbatim when a turn
continues past a tool call (a tool_calls row, then a stop row with identical
prose — both persisted). The turn merge folds both rows into one bubble, so
every paragraph in the reply rendered twice; inline ::preview frames made it
obvious. Repeated text parts now dedupe in the same pass as generated-image
echoes — the last occurrence wins.
2026-08-17 15:12:53 -05:00