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)
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>
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.
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>
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)
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).
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).
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.
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).
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.
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.
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).
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>
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.
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).
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).
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.
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).
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).
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.
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).
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`.
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).
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).
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
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.
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
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).
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
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).
`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.
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.
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).
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.
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>
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.
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
* 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>
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.
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.
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.
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.