Commit Graph

3851 Commits

Author SHA1 Message Date
John Paul Soliva
e67fd76fa2 fix(config): stop seeding global display values that override every messaging platform's defaults
The curl installer, the Windows installer, the Docker first boot and
`hermes doctor --fix` copy cli-config.yaml.example into config.yaml byte
for byte. The template had five display keys uncommented: tool_progress,
interim_assistant_messages, long_running_notifications, busy_ack_detail
and show_reasoning. The gateway reads config.yaml without a DEFAULT_CONFIG
merge, and resolve_display_setting takes a global display.<key> ahead of
_PLATFORM_DEFAULTS. So every seeded home ran with those values on every
platform. Telegram and Slack posted every tool call. Signal, email, SMS
and the other no-edit platforms got progress lines, heartbeats and
interim messages. Every messaging reply had the reasoning block prepended.

First-time `hermes setup` (quick and full) and Blank Slate setup also
wrote display.tool_progress: "all". That write was added as a Quick
Install recommended default (79aeaa97e6) nine days before the
per-platform tiers landed (#8006), and it has the same effect for
tool_progress on homes the template never touched.

`hermes config edit` on a home with no config.yaml wrote DEFAULT_CONFIG
unstripped, which pins show_reasoning (all 21 platforms),
interim_assistant_messages (12) and tool_preview_length (16). It now
seeds like the installer and `doctor --fix`: the template when the
checkout has one (a full file to edit, written owner-only), otherwise
DEFAULT_CONFIG with defaults stripped.

Measured through the real gateway loader across the 21 platforms in
_PLATFORM_DEFAULTS, a template-seeded home differed from a bare one on
tool_progress for 19 platforms, show_reasoning for 21, busy_ack_detail
for 14, long_running_notifications for 13 and interim_assistant_messages
for 12. With the pins commented out and the setup writes removed, the
diff is empty, and the same holds for both `config edit` seeds.

The CLI does not depend on these values. It defaults tool_progress to
"all" and show_reasoning to true when the keys are absent, and the TUI
defaults interim_assistant_messages to true.

Homes that were already seeded keep their values. A template value
cannot be told apart from one the operator chose, so there is no
migration. The messaging docs now say which lines to delete.

(cherry picked from commit 96450d4500613ab1ba45c7e972f31de570bc2d71)
2026-09-24 17:59:37 +05:30
kshitijk4poor
913b21c1e4 docs(cron): state the bounded, sparse-only quota recovery retry
The hold section promised a recovery retry for any cron job; the fix
limits it to sparse schedules and to one attempt per hold, so say so.

Co-authored-by: Marko Niskala <manis@aivelho.local>
2026-09-24 16:59:23 +05:30
Marko Niskala
ac10d34f61 fix(cron): recover sparse fires after quota cooldown
(cherry picked from commit 2ffdca938fb73da66d949a0685fa1f827b9088b7)
2026-09-24 16:59:23 +05:30
John Paul Soliva
dde9f3581d test(auth): accept token_bases in the refresh-race pool-store fake; doc the write boundary
write_credential_pool now takes token_bases, so the in-memory fake standing
in for auth.json in the refresh-race tests must accept it or every persist
in those tests raises TypeError inside the refresh thread. Also carries the
credential-pools doc sentence describing the stale-writer boundary.

Hunks taken verbatim from #120816.
2026-09-24 15:44:48 +05:30
kshitijk4poor
3da1c59377 fix(memory): refuse unpinned legacy replace/remove at approval
A destructive pending record staged before entry pinning carries only
its old_text search string. Replaying that search at approve time is
exactly the #120841 hazard: the live agent may have rewritten the entry
in place, and a newer entry that still contains old_text gets deleted or
overwritten. Naming the removed entry afterwards does not undo it.

So apply_memory_pending now fails closed: a replace/remove (single or any
batch op) without matched_entry is refused with nothing applied, the
record stays pending, and the message tells the approver to reject it and
recreate the change. /memory pending flags such records as an unpinned
legacy target so this is visible before approving.

The legacy case of test_approve_names_the_entry_a_remove_deleted now
asserts refusal + preserved record + intact entry; two existing tests that
hand-build replay payloads pin them with matched_entry, as real staging
does since the previous commit.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-24 15:38:22 +05:30
John Paul Soliva
b96e88b245 fix(memory): pin a staged replace/remove to the entry the approver reviewed
A staged replace/remove recorded only its old_text search string, and
/memory approve re-ran that search against the file as it was at
approval time. The background review stages every replace/remove it
wants (#105921), so when the live agent updated the targeted entry in
place and the new text still contained old_text, approving deleted or
overwrote the newer entry the approver never saw, and the output said
only "Approved 1 memory write(s)."

- Both staging gates resolve each replace/remove, under the store lock,
  to the full entry it matches and record it as matched_entry. No match
  or an ambiguous match is refused at staging, as the direct write is.
- Approval matches that entry exactly. If it changed since staging, the
  write is refused and the pending record is kept, as #96059 does for
  skills.
- /memory approve lists removed entries next to overwritten ones, so a
  record staged before this change (no matched_entry, still replayed by
  old_text) never removes an entry silently.
- /memory pending shows the full entry each staged replace/remove
  targets, not just its search string.

(cherry picked from commit 28700fa750c9321ded48be77a76531b5809f5fea)
2026-09-24 15:38:22 +05:30
JoaoMarcos44
439e3a3d25 test(profiles): cover cron distribution ownership
(cherry picked from commit b8b50f7521e10bbb6b66b1931965ce3383faeb90)
2026-09-24 15:37:00 +05:30
teknium1
be59f0411e fix(plugins): broadcast_plugin_event from the turn-isolation child reaches the Desktop
Review finding (MAJOR-2): with dashboard.turn_isolation the plugin's agent-side
code (tools, hooks) runs in the compute-host child, where _live_transports is
empty and _broadcast_global_event dropped the frame at logger.debug. The child
now registers its _HostTransport as a live transport for the life of run_host,
so a session-less broadcast rides the existing host pipe; the parent bridge
(_relay_compute_host_rpc) recognises an event frame with no session_id and
fans it out via _broadcast_global_event instead of write_json, which would
have dropped it on hermes serve's stdio.

Minor: the late `from tui_gateway.server import …` could raise ImportError in
a plugin-only process despite the "safe from any handler" docstring — caught
and logged as a warning.

Docs: the SDK page now tables exactly which process each call site runs in
and what it reaches (hermes serve / compute-host child / stdio TUI / gateway
run + chat + cron = nobody).
2026-09-24 02:03:03 -07:00
teknium1
5765f8d190 fix(plugins): desktop lint masks a new RegExp("<script…") only where it is used as a matcher
Review finding (MAJOR-1): masking the first argument of every new RegExp(...)
also hid `el.innerHTML = new RegExp("<script src=x></script>").source`, a real
injection through .source — desktop_surface_findings returned 0 findings for
that line. The literal is now masked only when the constructor is the argument
of .replace/.replaceAll/.split/.match/.matchAll/.search or the receiver of
.test/.exec; every other use (.source, .toString(), template interpolation) is
a string-builder and keeps firing. The existing test gains the .source line
(must flag) and a .test() line (must not).
2026-09-24 02:03:03 -07:00
teknium1
3593b63563 docs(plugins): broadcast_plugin_event contract, delivery scope and rss-reader migration
The listener receives the GatewayEvent (destructure payload), names are
dotted, delivery is per process (hermes serve = every Desktop window), and
the exact rss-reader switch away from the jsonl queue + 3 s poll. One line
in the catalog trust model on the lint's regex/RegExp allowance.
2026-09-24 02:03:03 -07:00
tobenwarrior
8034d493dc feat(plugins): public event bridge for plugin backends
A plugin backend pushing an update to its own desktop half imports
tui_gateway.server._broadcast_global_event — a core-private whose signature
is not a plugin contract. Give plugin authors a public, documented door.

hermes_cli.plugin_events.broadcast_plugin_event(plugin_id, event, payload)
emits plugin.<plugin_id>.<event> on the app's global event stream (the one
host.onEvent subscribes to), with the plugin id forced into the namespace and
the bare event name validated — a mangled name would strand the desktop half
waiting on a name nobody emits. Fire-and-forget, safe from any request
handler.

Wishlist item 8 of #116305; unblocks rss-reader (#115972).
2026-09-24 02:03:03 -07:00
teknium1
c2645ef14c fix(desktop): SandboxedFrame takes an explicit prop allowlist, not iframe attrs
Review finding (MAJOR) on #120927: the props type extended
`ComponentProps<'iframe'>` and the component spread them onto the element,
so a plugin could pass `allow="camera; microphone"` — the electron
`setPermissionRequestHandler`/`setPermissionCheckHandler` grant media
capture without looking at the requesting frame's origin, so that hands a
third-party site the app's mic/camera — plus `srcDoc` (replaces the `src`
contract with caller markup), `name`, `allowFullScreen`, `csp`,
`credentialless`. Props are now an explicit interface (className, style,
onLoad, onError, ref, sandbox, src, title) and nothing is spread, so none
of those reach the DOM even through a cast.

minor: `src` is scheme-checked — only http(s)/data render, anything else
(file:, blob:, javascript:, relative) renders nothing with a console.warn,
matching what the docs already promised.

minor: the SDK surface exports only `SandboxedFrame` + `SandboxedFrameProps`;
`sanitizeFrameSandbox`/`SANDBOXED_FRAME_DEFAULT_SANDBOX` stay module-internal.

Tests: the existing posture test now also asserts allow/srcdoc/name/
allowfullscreen never land on the element; one new test covers the scheme
refusal. Both were red on 17d1b046.
2026-09-24 01:53:34 -07:00
teknium1
a48557baff feat(desktop): trim SandboxedFrame tests to invariants; document the allowlist contract
Nine change-detector tests become four invariants (one per behaviour, two
behaviours per module): every realm-escaping / unknown / mixed-case token is
dropped and an emptied set falls back to the default posture; allowlisted
tokens survive deduped; the rendered frame carries the posture; props cannot
re-open it.

Docs: TS signature, the exact allowlist and the strip list the ruling names
(allow-same-origin, allow-top-navigation*, allow-popups*, allow-modals,
allow-storage-access-by-user-activation), teardown, and the rss-reader
(#115972) migration off its stubbed /preview → openExternal chain.
2026-09-24 01:53:34 -07:00
tobenwarrior
d81db86d61 feat(desktop): sandboxed embed primitive for plugins
A reader-style plugin embeds external content by mounting a raw Electron
<webview> on the app's persist:hermes-preview partition — sharing the app's
cookies and storage. Give plugin authors the app's own guest-content posture
instead.

SandboxedFrame renders a sandboxed iframe: opaque origin, allow-scripts by
default, no-referrer, lazy. Realm-escaping tokens (allow-same-origin,
top-navigation, popups, modals, storage-access) are stripped even when a
caller asks for them — a frame with no sandbox attribute is fully
privileged, so an empty result falls back to the default posture.

Wishlist item 9 of #116305; unblocks rss-reader (#115972).
2026-09-24 01:53:34 -07:00
brooklyn!
efafed5baf fix(desktop): Kanban attachment download through the one gated resolver
Fold the Kanban attachment download onto the existing gateway file-save
resolver (lib/media.ts downloadGatewayMediaFile / captureGatewayFileDownload)
instead of the source PR's second resolver (api/file-download.ts): one auth
shape, one owner-scope contract, for every Desktop gateway-file save.

- lib/media.ts: downloadGatewayMediaFile now accepts an explicit owner scope
  ({ connectionId, profile }) so a capture snapshot (Kanban) and the ambient
  $connection path (artifacts, chat previews) share one function. Adds
  downloadGatewayFileWithFeedback (the toast wrapper) and
  captureGatewayFileDownload (snapshots capabilityScoped() at read time).
- store/file-actions.ts: downloadRemoteFile is now a thin call into
  downloadGatewayFileWithFeedback -- the same fileMenu.downloadSaved /
  downloadFailed toasts the Files panel already used; cancel stays silent.
- plugins/kanban/drawer.tsx: rebased AttachmentDownload/AttachmentsSection
  onto main's Dialog-based drawer (aside "Attachments" section), using the
  app's boxless text-button treatment (size="inline" variant="text") to
  match the drawer's other inline actions, with a Tip for a long filename.
- sdk/index.ts: keeps captureGatewayFileDownload as the plugin SDK export
  (now sourced from lib/media, not a second module); the plugin docs keep
  it as a void-returning, non-throwing capture (toasts already fire inside).
- types.ts: keeps KanbanAttachment.stored_path.
- Deletes api/file-download.ts and its resolver; drawer/file-actions tests
  adapted for the new call shape plus a local-mode ownership case.

Root cause: the Kanban drawer only ever rendered the attachment filename
with no action, so on macOS (and everywhere else) there was no way to fetch
the stored file at all -- the backend already returns stored_path
(plugins/kanban/dashboard/plugin_api.py) but nothing in the renderer used it.

Tests: apps/desktop `npx vitest run --project ui src/plugins/kanban
src/lib/media src/api src/store/file-actions` (112 passed); the new drawer /
media-file-download / file-actions cases fail on main first (confirmed
against a scratch main worktree) and pass here. `npm run typecheck` clean.

Limits: attachments with no stored_path (older backend rows) keep the
control disabled rather than guessing a workspace path, matching the source
PR's compatibility stance.

Fixes: https://github.com/NousResearch/hermes-agent/issues/85672
Supersedes: https://github.com/NousResearch/hermes-agent/pull/107370
Supersedes: https://github.com/NousResearch/hermes-agent/pull/110161
Supersedes: https://github.com/NousResearch/hermes-agent/pull/87727

Co-authored-by: Johan Roest <229638764+jroest@users.noreply.github.com>
2026-09-24 03:42:42 -05:00
Johan Roest
d9a44a0ed4 fix(desktop): download Kanban attachments through authenticated gateway
(cherry picked from commit 1f9e64b0ed27f68885ad0a211914e7c10736c0ab)
2026-09-24 03:42:42 -05:00
teknium1
55c52f2902 fix(desktop): appearance extras use the pane boundary and render on the top-level page only
Review findings (minor) on #120912:

* `AppearanceExtraSlot` wrapped each contribution in `ContribBoundary
  variant="chip"`, so a page-level plugin card that threw collapsed into a
  bar-item chip meant for toolbar slots. Use the default `pane` variant —
  the canonical ErrorState with Retry, matching every other zone body.

* The slot was mounted unconditionally, so every deep-link subpage
  (`settings/appearance/<section>`, which shows exactly one built-in
  section) also grew the plugin cards. Gate it on `subpage === undefined`
  like the rest of the page's top-level-only chrome.

Tests: the boundary test now asserts the pane fallback (red on c322f692);
one new page-level test renders `AppearanceSettings` with and without a
subpage and asserts the extra mounts only on the top-level page (red on
c322f692). Docs updated to the new contract.
2026-09-24 01:34:59 -07:00
teknium1
84ac69e884 feat(desktop): keep ColorSwatches export as on main; document APPEARANCE_AREAS.extra contract
Drop the salvaged ColorSwatches re-export hunk: main already exports the
component from the SDK, and the plain-JS plugins this slot serves do not need
the props type, so the shared sdk/index.ts hunk stays a single added line.

Docs: TS signature, arbitration (every registration mounts in its own
boundary — no first-wins, plugins cannot suppress each other), teardown
(loader-owned disposer, nothing persisted) and the exact migration for
better-session-appearance (#115961) and hermes-appearance-hub (#116049).
2026-09-24 01:34:59 -07:00
tobenwarrior
8d71403ffa feat(desktop): appearance-settings slot and the app's swatch grid for plugins
A plugin adding appearance controls injects nodes into Settings → Appearance
and drives the app's widgets through React internals. Give it a seam.

APPEARANCE_AREAS.extra renders contributions at the end of the Appearance
page (own error boundary each, chip fallback), and ColorSwatches — the grid
the profile rail and project dialog already use — joins the public SDK
surface with its props type, so a plugin picks colours with the app's own
control and its own onChange (pair it with host.sessions.setColor for
session colours).

Wishlist item 7 of #116305; unblocks better-session-appearance (#115961).
2026-09-24 01:34:59 -07:00
teknium1
4cec3f85e7 fix(desktop): host.pluginDecisions hands out frozen copies, not the live store object
Review finding (MAJOR) on the typed bridge: `pluginDecisions.get()`,
`.value` and the `subscribe`/`listen` callback argument returned the
store's own object. `pluginActive()` reads that same reference and
`saveDecisions({...$pluginDecisions.get(), [id]: enabled})` spreads it,
so `host.pluginDecisions.get()['other'] = false` silently disabled
another plugin and the next toggle persisted it — the declined `set()`
by another door.

Every value the read-only view hands out is now `Object.freeze({...v})`;
assignment throws under strict mode and the store is untouched. The
existing read-only test gains the mutation assertion (red on c1b740ac).
Docs: frozen-copy contract spelled out; cheat-sheet signature for
`host.profiles.list(scope?: ProfileScope)` made explicit.
2026-09-24 01:25:11 -07:00
teknium1
7daf8180f7 docs(desktop): capabilities bridge section; pluginDecisions documented read-only
Signatures, the profile-scoping rule, the WHY of the declined set() writer,
teardown note, and the exact better-capabilities migration table.
2026-09-24 01:25:11 -07:00
tobenwarrior
bd55633248 feat(desktop): typed skills/toolsets/profiles bridge for plugins
Plugins configuring capabilities called window.hermesDesktop.api raw and
read/wrote the persisted pluginDecisions map directly. Give them the same
doors the Capabilities page uses.

host.skills.list/setEnabled, host.toolsets.list/setEnabled, and
host.profiles.list wrap the app's own api modules — same endpoints, same
profile scoping (a ProfileScope configures any profile without swapping the
app-wide active one) — and host.pluginDecisions exposes the decisions map
with set() going through the live toggle (deactivate/activate included),
never a raw storage write.

Wishlist item 6 of #116305; unblocks better-capabilities (#115960).
2026-09-24 01:25:11 -07:00
teknium1
6b2c23ae42 fix(desktop): model-pill provider must return a string; non-strings fall through
Review finding (MAJOR) on #120919: useComposerModelPillLabel accepted any
truthy return with `if (label)`. ModelPill drops the value straight into JSX
with no error boundary, so a plugin returning an object/array/number threw
"Objects are not valid as a React child" and blanked the whole composer —
exactly the failure mode the hook's throw-swallowing was meant to prevent.
Only a non-empty, non-whitespace string is now a label; anything else
declines like null.

Review finding (minor): the two existing tests are tightened instead of
adding new ones. The compact-mode "providers skipped" assertion was
`queryByText(...)` on a chevron-only render and passed regardless; it is now
a spy that must not be called. The throw test now also covers the
non-string return (red on b559ac9c: React child error) and two non-null
providers: the first registered string wins and the later provider's spy is
never invoked.

Docs: arbitration section states the string-only contract and that
`reasoningEffort` is always a string ('' when none).
2026-09-24 01:12:58 -07:00
teknium1
1b77007a36 docs(desktop-sdk): model-pill label providers — signatures, arbitration, migration
Spell out the contract plugins will lean on: the context fields, the
"registry order, first non-null wins, throw = decline" rule, that compact
mode never consults providers, and that teardown is the contribution's
own disposer (nothing for ctx.onDispose). Include the exact migration for
compact-reasoning-label, noting that the core label no longer carries the
effort word, so the plugin's regex strip is already a no-op and the hook
is the place to compute a label rather than rewrite rendered text.
2026-09-24 01:12:58 -07:00
tobenwarrior
2b4a5a0924 feat(desktop): model-pill label providers for plugins
The compact-reasoning-label plugin rewrites the model pill's textContent
through a MutationObserver to show a compact reasoning label. Give it the
sanctioned seam instead.

COMPOSER_AREAS.modelPill takes data contributions with a label(ctx) resolver
({ model, reasoningEffort, compact }); the first non-null label wins and a
declining (or throwing) provider leaves the core label. The pill keeps its
chrome, pin dot, and menu — only the text changes.

Wishlist item 5 of #116305; unblocks compact-reasoning-label (#115962).
2026-09-24 01:12:58 -07:00
teknium1
2553b8660b fix(desktop): sidebarNav.prefs cannot hide the Plugins row; document the real order rule and namespaced ids
Review findings on #120922.
- Hiding `capabilities` removed the row that hosts the Plugins tab, the
  user's only path to a plugin's own off-switch — the design law forbids a
  plugin locking the user out of disabling it. `NEVER_HIDDEN` keeps that
  row; it can still be re-ordered.
- Docs said "first-registered order wins", but `registry.getArea` sorts by
  `Contribution.order` then insertion, so a later plugin with `order: -1`
  pre-empts. The registry exposes no insertion order, so the docs (and the
  SDK/store comments) now state the real rule: lowest `order`, then
  registration. A registry-backed test pins it.
- Docs said a contributed row's id equals its `SIDEBAR_NAV_AREA` id, but
  `createPluginContext.scope` namespaces it to `${pluginId}:${id}`; the
  docs now give the namespaced form to use in `hide`/`order`.
2026-09-24 00:59:20 -07:00
teknium1
7fba0e6e73 feat(desktop): sidebar nav prefs become a sidebarNav.prefs contribution
Replace the persisted `host.sidebar.hide()/setOrder()` store from #116409
with a registry contribution area (`SIDEBAR_NAV_PREFS_AREA`), keeping the
contributor's pure merge function (now `applySidebarNavPrefs`) fed from
`useContributions()` instead of two persisted atoms.

Why: `host` is a module singleton that cannot attribute a write to the
plugin that made it, so a persisted preference outlives the plugin (a
hidden row with nothing left to restore it) and two plugins overwrite each
other's order. A contribution is attributed, merged with a stated rule and
dropped by the loader's per-plugin disposer on disable/reload, so the rows
come back on their own.

Arbitration: hidden = union of every contribution's `hide` (hide beats
order); order = first-registered contribution wins, later ones place only
ids not yet placed; unknown ids inert; unnamed rows keep default relative
order after the named ones. The user's choices persist in the plugin's own
`ctx.storage`; it re-contributes them (same id replaces).

No `host.sidebar` key, no `hermes.desktop.sidebarNav*` localStorage keys.
Tests: one pure arbitration invariant + the sidebar restoring the row on
dispose (red on origin/main).
2026-09-24 00:59:20 -07:00
tobenwarrior
632a5cd1c8 feat(desktop): sidebar nav visibility and order for plugins
A sidebar-manager plugin had no SDK door for moving or hiding nav rows —
today it hides core rows with CSS display:none and re-parents React-owned
children. Give it a preference store core reads at render.

host.sidebar.hide(navId, hidden?) and host.sidebar.setOrder(ids) write the
persisted nav-preference store; the sidebar applies them through one pure
function (orderSidebarNav) so the semantics are testable: hidden ids drop,
named ids come first in that order, unnamed rows keep their default relative
order after them, unknown ids are inert. The preference never mutates the
default list — removing the plugin leaves every row where it was.

Wishlist item 4 of #116305; unblocks sidebar-manager (#115973).
2026-09-24 00:59:20 -07:00
brooklyn!
511b879c40 docs(update): document rescue refs for local commits on the target branch 2026-09-24 02:40:53 -05:00
brooklyn!
80e30634a5 fix(tts): keep local voices warm for tts.keep_warm_seconds after the last lease drops
release_tts_lease() unloaded resident local models inline the moment the
holder count hit zero, so a wake-only voice conversation that ended right
after its ~2s Piper warm-up evicted the voice, and every wake reloaded it.
The last release now schedules the unload on a cancellable timer
(tts.keep_warm_seconds, default 60, 0 = immediate); any acquire in the
window cancels it, and a warm-up that finishes with no holders restarts
the window so a mid-load release can't leave the model resident forever.

Fixes #118037
2026-09-24 02:39:10 -05:00
teknium1
4f3426e7ca fix(desktop): host.sessions.reorder writes live ids; add reorderPinned; move verbs to sdk/sessions.ts
Review findings on #120916:

- MAJOR: `$sidebarSessionOrderIds` is keyed by the LIVE session id — the drag
  path persists `reorderableRowIds` (`session.id`) and the sidebar reconcile
  effect keeps only ids present in `unpinnedAgentSessions.map(s => s.id)`.
  The row slots hand plugins the DURABLE (lineage-root) id, so feeding those
  back into `reorder` dropped every compressed session from the order and,
  when none survived, flipped `manual` off on the next render. `reorder` now
  maps each id to its loaded row's live id through `sessionMatchesStoredId`
  (the same matcher pin uses). Test: reorder with `_lineage_root_id != id`
  was red on head (`['c','a','b']` written verbatim), green now.
- minor: the Pinned drag path (`setPinnedSessionOrder`) had no verb, so
  drag-to-pin-session could not port its pinned reorder. `reorderPinned(ids)`
  delegates to it after durable-id resolution; unmentioned pins keep their
  slot (test red on head: property did not exist).
- minor: `durableSessionPinId` already resolves a loaded id through lineage;
  the unloaded pass-through is now stated in the docs (pass the slot's durable
  id for rows that may have scrolled out).
- shape: the ~55 verb lines appended to `sdk/index.ts` move to
  `sdk/sessions.ts` (`sessionsHost`); index.ts is import + `sessions:` +
  the SESSION_ROW_AREAS export.
- docs: `reorderPinned` in both API blocks; `SESSION_ROW_AREAS` /
  `SessionRowSlotContribution` in the exports-at-a-glance table.
2026-09-24 00:31:25 -07:00
teknium1
9d8ae38850 feat(desktop): host.sessions.pin takes a drop index; reorder([]) resets the manual order
drag-to-pin-session drops a row between two pins, so `pin(id)` appending to
the end of the Pinned list is not enough — it read `pinSession(id, index)`
off the row's fiber for exactly this. The store already accepts the index;
expose it as the verb's third argument so a drop lands where it was aimed.

The same plugin's "reset" path calls `reorder([])`. The app's own drag
handler reaches the default sort for an empty list only through the
sidebar's reconcile effect one render later; the verb states it directly
(manual = ids.length > 0) so a plugin reset never depends on a mounted
effect, and the semantics are documented rather than incidental.

Tests trimmed to invariants: the two slot tests cover both areas + the
durable id and late-arrival + teardown; the pin/reorder tests gain the index
and reset assertions. All six were red against origin/main's session-row.tsx
and sdk/index.ts and green on head.

Docs: TS signatures, arbitration (all slots mounted, own boundary; verbs =
last write wins on user data), teardown, and the exact migration for
drag-to-pin-session (#115963) and better-session-appearance (#115961).

Part of #116305
2026-09-24 00:31:25 -07:00
tobenwarrior
585e296db5 feat(desktop): session-list API and row-decoration slots for plugins
Plugins that manage or decorate sessions had no SDK door for it: they walked
the row component's fiber for onTogglePin/onReorderSessions props and wrote
the persisted sessionColors key directly. Give them the same doors core uses.

host.sessions.pin / reorder / setColor write the stores the app's own
controls write — the row's shift-click, the drag persist, the colour picker —
so a plugin action and a hand click can never disagree. Ids resolve through
the app's lineage matcher to the durable (lineage-root) id, so pins and
colours survive compression's session-id rotation.

SESSION_ROW_AREAS.leading / .trailing render slots let a plugin decorate a
sidebar row, with the row's stored session id handed to its render. Mounted
like chat.empty: own error boundary, can subscribe to its own stores, every
registration mounted so a declining plugin cannot suppress the owner.

Wishlist item 3 of #116305; unblocks drag-to-pin-session (#115963) and
better-session-appearance (#115961).
2026-09-24 00:31:25 -07:00
teknium1
21f6e35643 docs(desktop): host.settings — teardown rule, declined keys, consumer migration
Move the section out of the middle of the host.request explanation to the end
of "Host API" and complete it per the #116305 design: TS signatures; the
arbitration rule (closed allowlist -> owning store setter, sync throws, never
localStorage); the teardown rule (host is a module singleton and cannot
attribute a subscriber, so plugins must ctx.onDispose the subscribe disposer);
the declined keys with the reason each stays out (keybind map -> KEYBINDS_AREA,
theme/mode -> THEMES_AREA, pluginDecisions -> Plugins tab, appearance-hub's
extra keys -> per-store follow-ups); and the exact hermes-appearance-hub /
prompt-snippets migration off localStorage + synthetic StorageEvent.

Part of #116305
2026-09-24 00:19:42 -07:00
Jony
748f0398e3 feat(desktop): expose typed plugin settings gateway 2026-09-24 00:19:42 -07:00
teknium1
ef70b3661c fix(desktop): host.composer 'new' fails closed, single-owner draft replies, verbs in sdk/composer.ts
Review findings on #120907:

- MAJOR-1: resolveComposerAddress mapped 'new' to target 'active', so
  insertText/submit/focus('new') landed in whatever composer the bus
  routed to (a tile showing session X) while the docs promised the
  session-less draft. 'new' now resolves to the primary composer only
  while it shows no session ($activeSessionId and $selectedStoredSessionId
  both empty — the same condition under which its getIds() is
  ['__new__']), otherwise to no target and the verbs return false / drop.
- minor-1: an id-addressed draft request was answered by every owning
  surface (primary pane + keep-alive tile of one session both painted).
  The first owner stamps `claimed` on the shared detail; later owners skip.
- minor-2: getDraft's stash fallback keyed on ids[0] (the requested,
  possibly runtime, id); the stash is keyed by the stored id, so a
  runtime-addressed read of an unmounted session came back null. Key on
  the resolved stored id.
- minor-3: JSDoc claimed an absent surface "rejects"; nothing rejects —
  wording now matches the null/false contract.
- shape: the ~130 lines of verb bodies + resolver move out of the
  sdk/index.ts facade into sdk/composer.ts (`composer: composerHost`),
  like the settings/bridge lanes.
- tests: collapse the six "no surface -> null/false" assertions and the two
  self-responding plumbing tests; each behaviour keeps <= 2 tests, the
  three findings above are folded into existing tests (all red on 8fe7b682).
2026-09-24 00:01:41 -07:00
teknium1
931619742b docs(desktop-sdk): host.composer signatures, arbitration, consumer migrations; document COMPOSER_AREAS.underside
`underside` has existed in contrib.ts since the composer dock landed but the
SDK page never listed it, so next-prompt registers the string literal. Spell
out the fail-closed arbitration rule and the no-teardown property of the
draft verbs, and give each held catalog plugin its exact reach-in -> SDK
replacement so the migration is copy-paste.
2026-09-24 00:01:41 -07:00
Binshao
6b3c0de462 fix(composer): gate active draft requests on isActive and acknowledge SDK inserts
Two contract edges from the #116389 review pass:

1. active draft requests now require address.isActive() — every mounted
   surface used to claim them, so listener registration order, not the
   focus bus, decided who answered; with keep-alive tabs in the stack a
   buried composer could read the active draft and a set painted onto
   every mounted draft.
2. host.composer.insertText returns Promise<boolean> via an acked insert
   on the bus (same trim + deferred dispatch, plus a token the claiming
   surface echoes back). Blank text, no claimant, or a refused write
   settle false — the fail-closed semantics setDraft/submit already
   use — instead of a silent no-op.

Both insert subscribers acknowledge; internal fire-and-forget inserts
(no token) are unchanged.
2026-09-24 00:01:41 -07:00
Binshao
a874cee625 feat(desktop): expose composer draft API to plugins (host.composer)
Implements item 1 of #116305 as a thin facade over the app's own composer
bus — no new state, no behavior change for non-plugin paths:

- host.composer.getDraft/setDraft/insertText/submit(sessionId | null, ...)
  addressing: null = active, stored/runtime id = that session's primary or
  tile surface, 'new' = the session-less fresh draft.
- focus.ts: hermes:composer-get-draft / -set-draft request/reply pair
  (50ms timeout settles null/false; a non-answering subscriber can never
  strand the caller).
- use-composer-draft.ts: mounted composers answer for their live DOM text
  (paintDraft owns writes, so @-ref / / tokens hydrate as chips like
  official paste); hidden keep-alive surfaces answer too — paintDraft
  never steals a visible caret.
- store/composer.ts: export NEW_SESSION_DRAFT_KEY for address mapping.
- tests: bus-level (focus.test.ts, 5) + facade-level (sdk/index.test.ts,
  4: addressing, live read, stash fallback, timeout-null, refused write).
- docs: 'Composer draft API' section in desktop-plugin-sdk.md.

Unblocks the DOM-reach-in in prompt-snippets (#116030), prompt-enhancer
(#116031), and the insert path of appearance-hub (#116049).
2026-09-24 00:01:41 -07:00
Jeffrey Quesnelle
7de8728cba Merge pull request #108345 from afourniernv/fix/relay-atof-cwd
fix(relay): include cwd in session and turn scopes
2026-09-24 01:06:16 -04:00
teknium1
72d18ce59f fix(plugins): subdirectory installs download only that folder
A plugin that lives in a monorepo subdirectory (the Hindsight catalog entry:
vectorize-io/hindsight#hindsight-integrations/hermes) was cloned with every
file in the repository even at --depth 1: 170 MB for a 2 MB plugin folder.
On a slow link that exceeds any reasonable deadline, so `hermes update`'s
Hindsight migration failed with "Git clone timed out" every time.

Subdirectory installs now do a blobless clone (--filter=blob:none
--no-checkout) and a sparse checkout of just that folder, written as the
classic info/sparse-checkout file so older Git clients work too. Servers
without partial-clone support ignore the filter and fall back to the old
download.

Because a partial clone downloads file contents at checkout time, the
checkout step (pinned and unpinned) now gets the configured network
deadline and the credential fallback, same as clone and fetch. Every
timeout error names plugins.clone_timeout_seconds so users can find the
knob.

Existing test fakes of _clone_plugin_repo accept the new subdir argument.
2026-09-23 21:29:21 -07:00
Gille
5dbbc9a9c0 fix(cli): make plugin clone timeout configurable 2026-09-23 21:29:21 -07:00
IAvecilla
686c34d3f6 feat(bot_desktop): ship Bot Screen on hosted images (-desktop tags)
The published image had no Xvnc/Xfce because nothing set the Dockerfile's
HERMES_BOT_DESKTOP argument, and a hosted instance (unprivileged, no sudo,
sealed /opt/hermes) cannot install at run time. The image layer is the only
delivery path.

- docker.yml: variant axis [slim, desktop]. :latest / :main / :v* stay the
  image they are today; :latest-desktop / :main-desktop / :v*-desktop carry
  the packages plus Playwright's headed Chromium. Slim owns the build cache
  scope; one manifest per variant so a desktop publish failure never skips
  slim's :latest.
- Dockerfile / stage2-hook.sh: XDG_RUNTIME_DIR=/tmp/hermes-runtime seeded
  0700 as hermes (containers have no logind; the $HOME/.cache fallback was
  the shared /opt/data volume), refused when foreign-owned; deterministic
  Chromium discovery exporting the headless shell for ordinary browsing.
- bot_desktop: memory gate reads the cgroup working set (usage minus
  inactive_file) so it cannot tighten over uptime and refuse to restart a
  screen idle-stop just stopped; installable() gives three distinct dead-end
  messages instead of a sudo line nobody there can run; env_for_agent
  replaces a headless-shell pin so agent and dock share one Chromium.

Squash of IAvecilla/hermes-agent:bot-desktop-cloud-image (#112381, 13
commits), which GitHub auto-closed when its base branch merged as #108914.
Review fixes from pefontana (cache scope, per-variant merge, red browser
test) are included.

Co-authored-by: pefontana <pefontana@users.noreply.github.com>
2026-09-23 19:54:47 -07:00
teknium1
8eda97cafb fix(webhook): make delivery mirroring opt-in and profile-scoped
Follow-up to the salvaged mirror commit.

- Opt-in (`mirror_to_session: true`, or `hermes webhook subscribe --mirror-to-session`),
  default off like cron's `mirror_delivery`. The mirrored text lands with user authority in
  the target chat, and on `deliver_only` routes it is the raw rendered payload, so a route
  author has to ask for it. Only a real boolean `true` opts in (a YAML string "false" no
  longer does).
- The mirror runs inside the routed profile's scope, so a `/p/<profile>/` delivery writes
  into THAT profile's state.db. A Telegram DM chat_id is the user's id on every bot; an
  unscoped mirror landed in the default profile's DM with the same person (live-probed).
- Tests trimmed to two invariants against a real state.db: an opted-in delivery lands in the
  routed profile's chat session and not the default profile's; a route without the opt-in
  (including `mirror_to_session: "false"`) never touches the target transcript.
- Docs: webhooks guide section "Replying to a delivery" (default, profile scope, trust note),
  CLI reference row, bundled hermes-agent skill reference.
2026-09-23 17:58:45 -07:00
Patrick Blais
11fb429f49 feat(webhook): mirror cross-platform deliveries into the target chat session
A webhook route that delivers to telegram/discord/... runs in an ephemeral
webhook:<route>:<delivery_id> session. The delivered text never reaches
the target chat's own session, so when the user replies there ("so he's
out?") the agent has no idea it just sent them anything and asks what
they mean.

After a successful cross-platform send, mirror the delivered text into the
target chat's transcript as a labelled user turn ("[Webhook delivery:
<route>]\n..."), the same path and role convention cron briefs use
(cron.scheduler_delivery._maybe_mirror_cron_delivery, #2221). Best-effort:
never fails the delivery, skipped when the chat has no session yet.
Route opt-out via mirror_to_session: false. delivery_info now carries the
route name and the flag for both agent-run and deliver_only routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ15jNgPUHdPsMw4wWon6v
2026-09-23 17:58:45 -07:00
Austin Pickett
d94769da64 fix(update): a Desktop-only host settles an inventory-less restart obligation (#120740)
* refactor(update): one predicate for serve rows outside the gateway matrix

The inventory branch of `_marker_only_restart_obsolete` inlined the rule for which
serve/dashboard rows the gateway matrix neither covers nor needs to (supervisor-owned,
or a manual serve handed to its own reminder). The inventory-less branch needs the same
rule for #118742, so it moves to `update_cmd_fleet_gatewayless.runtime_outside_gateway_evidence`
and both branches will read one definition. No behaviour change.

* fix(update): a Desktop-only host settles an inventory-less restart obligation

A host that runs no gateway (the Desktop app alone) can be left with an inventory-less
fleet-restart obligation: an updater that died before recording its inventory, or the
pre-inventory writer. With no owed set, the live gateway matrix is its only evidence, and
on that host the matrix is empty forever, so `_marker_only_restart_obsolete` never settled
and every later `hermes update` exited 1 with "gateways are still off the checkout code"
(#118742).

An empty fleet alone cannot tell that host from one whose gateway the dying update stopped,
so the inventory-less branch now asks the live host, never a historical receipt:
`host_owes_no_gateway_restart` settles only when no profile's `gateway_state.json` claims a
state other than stopped/startup_failed (a gateway that went away without a clean stop keeps
the obligation) and every live runtime sits outside the gateway matrix
(`runtime_outside_gateway_evidence`, shared with the inventory branch). HEAD must still
contain the pulled SHA (`checkout_contains`, same rule as #119367). Probe failures keep it.

Tests: the scoped-reconciliation matrix now holds its host at "the update stopped a gateway"
so it keeps pinning receipt independence; a new host-evidence matrix covers Desktop-only,
clean stop, carried commit, stopped gateway in a named profile, unclassified and
unidentified serves, a gateway row without fleet identity, and a diverged checkout. Two
manual-serve tests that assumed an empty fleet always stays pending now stub the live host
and assert the manual reminder survives the gateway obligation settling.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>

---------

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
2026-09-23 18:48:33 -05:00
brooklyn!
cb7b1b32dd fix(desktop): keep English stop defaults, Unicode-safe matching, configurable barge-in (#117801)
Builds on the salvaged voice.stop_phrases matcher:

- /api/config merges DEFAULT_CONFIG, so an untouched install reports
  stop_phrases: ["stop"] instead of omitting the key. Compare against
  /api/config/defaults so that case keeps the built-in English list
  ("goodbye", "never mind", ...) and only a list the user changed replaces
  it. Parse malformed values (mapping, null) as the backend does: default.
- Normalise transcripts and phrases with NFKC and Unicode punctuation
  (\p{P}), so NFD Cyrillic from STT, «guillemets» and CJK full stops match.
- Wire the existing voice.barge_in_threshold_multiplier into the desktop
  barge-in monitor: it scales the quiet-floor multiplier and the playback
  clamp (PLAYBACK_MIN_TRIGGER_LEVEL) by its ratio to the backend default
  (3.0), so stock behaviour is unchanged and a quiet BT HFP headset can
  interrupt with a lower value. No new setting or env var.
2026-09-23 18:30:27 -05:00
teknium1
94c0e49307 docs: scope the ACP toolset parity wording
ACP resolves toolsets like the gateway for the same platform config,
which includes plugin toolsets. An empty platform_toolsets.acp list still
adds enabled plugin toolsets, so the docs point at agent.disabled_toolsets
for removing them.
2026-09-23 16:27:05 -07:00
teknium1
316d6fb8f7 fix(acp): admit config MCP servers by platform_toolsets.acp like the gateway
A fresh ACP agent appended mcp-<server> for every enabled config MCP server
unconditionally, so a platform_toolsets.acp allowlist of server names and the
no_mcp sentinel were ignored on ACP while the gateway honoured both.

The MCP half now comes from the same _get_platform_tools(config, "acp") call
as the base toolsets: its server names (default every enabled server, a listed
allowlist, or none for no_mcp) are keyed as mcp-<server>. Editor-provided
session/new servers are unchanged.

Docs: `hermes tools` has no ACP platform entry, so drop the claim that it
configures platform_toolsets.acp; document the MCP rules with a config example.
2026-09-23 16:27:05 -07:00
teknium1
5fb5e743b3 fix(acp): resolve ACP toolsets through the shared platform resolver
A fresh ACP agent hardcoded enabled_toolsets=["hermes-acp"], so
platform_toolsets.acp never narrowed the editor tool surface, unlike the
gateway, cron and api_server which all resolve via
hermes_cli.tools_config._get_platform_tools. Resolve the ACP base the same
way (ACP keeps appending its own mcp-<server> entries), and treat only None,
not an explicit empty list, as "use the hermes-acp default" in the /tools
and MCP-refresh rebuilds so a deny-all list cannot re-widen mid-session.

With the unconfigured default the resolved tool definitions are
byte-identical to the hermes-acp composite, so existing sessions keep the
same tool list and prompt cache.

The fresh-session assertion in test_make_agent_prefers_passed_toolsets_over_config_servers
now checks membership of the config MCP entry: the resolver returns the
expanded toolset keys rather than the bare composite name.

Refs #74582, #79516. Credit: #64045 (@israellot), #80309 (@thatssoheil),
#106834 (@nicolasramos) proposed the resolver routing.
2026-09-23 16:27:05 -07:00