601 Commits

Author SHA1 Message Date
teknium1
01d85137bd fix(review): MCP owner rebuild keeps the launch profile's env-only credentials (#119092 review)
_install_owner_secret_scope / _owner_secret_scope rebuilt every owner's mapping with
build_profile_secret_scope (.env + external sources only). For the LAUNCH profile under
multiplexing the caller's bound mapping is launch_secret_scope's (frozen launch env under its
files), so a credential injected only by systemd Environment= / `op run` / Compose vanished on
the rebuild, the remote header stayed the literal ${VAR} and the new fail-closed check parked a
server that worked on main. Route the rebuild through _owner_secret_mapping: launch_secret_scope
for the process home (same rule kanban_db_dispatch applies), build_profile_secret_scope for a
served profile.

Also: retry a not-fully-hydrated home's secret sources at most once per 30 s per home instead of
on every connect/reconnect (each retry is a helper subprocess); document the remote url/headers
${VAR} fail-closed error and the scoped A2A/Buzz gates in the MCP config reference and the
multiplexing guide.
2026-09-28 05:24:26 -07:00
Siddharth Balyan
98278833e2 Compaction follows compression.threshold again: no default 256K token cap (#117915, #125235) (#126064)
* fix(compression): compaction follows the ratio again; no default token cap

compression.threshold_tokens defaulted to 256000 since #115986, so every
window above ~341K compacted at 256K regardless of compression.threshold
or model_thresholds: a 1M-window model compacted at 25% of its window,
and threshold: 0.8 still compacted at 256K. No single token count suits
windows from 64K to 1M+, so the cap goes back to opt-in (null) and the
ratio decides, as it did before #115986.

The template seeder and `hermes doctor --fix` copied the 256000 default
into config.yaml, where it reads as a user choice and would keep capping
those installs. Config v47 drops threshold_tokens only when it equals
256000; any other explicit cap and an explicit null are preserved.

The rest of #115986 stays: the model-switch warning quoting the real
trigger and the shared _derive_trigger are correct with any default.
Tests that encoded 256K now derive expectations from DEFAULT_CONFIG.

* docs(compression): threshold_tokens is an optional cap, default null

User guide, developer guide and delegation page described the 256K
default cap; they now describe the ratio trigger as the default and
threshold_tokens as an opt-in cost ceiling.
2026-09-28 07:29:40 +00:00
Brooklyn Nicholson
929de15c73 docs(desktop): correct WorkspacePageHeaderControl older-host note
Some checks failed
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
A named import of a missing SDK export fails to link through the runtime
shim, so the plugin never loads; it is not undefined. Point authors at a
namespace-import feature-detect or the raw Contribute form.

Refs #123597

Originally authored by Justin Haynes (@jhaynes).
2026-09-27 13:18:46 -05:00
Brooklyn Nicholson
aa25f9e85f fix(desktop): Kanban board switcher outside the full-page layout
A Kanban board opened in a split route tile rendered no board switcher:
the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally,
and only the workspace pane paints that area. The tile's contribution
also leaked into another page's header and shared its id with the full
page's, so closing the tile removed the page's switcher.

Add WorkspacePageHeaderControl (exported via the plugin SDK). The
workspace pane's render provides a private host context; inside it the
control projects into the page header, anywhere else it renders inline.
The board mounts BoardSwitcher once, through it, in its own header row.

Fixes #123597

Originally authored by Justin Haynes (@jhaynes).
2026-09-27 13:18:46 -05:00
Adolanium
d1167fdff7 fix(desktop): match custom:<key> providers in the settings and bot model pickers
model.info and saved profiles report a user-defined provider as custom:<key>, but the catalog row uses the bare key as its slug. Settings > Model and the Bot Mode picker compared the two with ===, so a saved custom provider never found its row. Settings showed a duplicate custom:<key> entry and a Set up provider button, and the bot editor fell back to the manual form.

Both now match rows with catalogProviderMatches, like the composer picker already does. Settings uses a small findCatalogProvider helper for every row lookup, including the aux and MoA slots and the endpoint passed on Set to main. catalogProviderMatches is now exported through the plugin SDK so the bot picker can use it.
2026-09-27 12:49:56 -05:00
Adolanium
e46d4c0ade fix(compression): let the next summary build on a fallback handoff
A deterministic fallback summary replaced the older handoff in the transcript but never updated _previous_summary. The next compaction kept the stale in-memory summary and dropped the fallback row from its window, so the fallback's user asks, files and last dropped turns never reached the summarizer. Store the fallback body in _previous_summary the same way a normal summary is stored.

(cherry picked from commit 35417d2e1ffbb775c3eaff17b26623896afa56c1)
2026-09-27 18:20:40 +05:30
MongLong0214
bb17b1f74c fix(compression): keep the pending round's images when it fits
Compaction spared a pending tool round's text results but not its
images. The image-retirement pass kept only the newest three image
results across the transcript, so four parallel vision calls in one
unread round lost the oldest image before the model saw any of them.
On a single-prompt run the final media pass lost three of the four:
compaction re-appends the task as a user row after the round, and the
pending round was looked up after that row was added, so nothing was
spared.

Both image passes now skip a pending round that fits the hard share,
and the pending round is found before the task row is re-appended.
Completed rounds and a pending round over the hard share keep the
existing policy.

(cherry picked from commit eb7661f4365f009d5f9ef85e26f2f4b6ac83632e)
2026-09-26 23:45:36 +05:30
MongLong0214
65f185c594 fix(compression): keep the pending tool round when two steers follow it
The pending-round check skipped only one trailing /steer row. Two can
land in the same iteration: one is appended when the tool batch ends,
and a steer sent after that is drained before the next request and
inserted right after the newest tool result. The transcript then ends
tool -> steer -> steer, so the check stopped on the second steer row,
found no pending round, and preflight compaction replaced the unread
tool output with the one-line pressure stub.

The check now skips every contiguous trailing steer row, identified the
same way as before, and still stops on any other user row. The existing
regression gains a case with two steer rows after the round.

(cherry picked from commit 455a1a3868b3843d15401781363ce796bae96b5b)
2026-09-26 23:45:36 +05:30
MongLong0214
18eb09f5db fix(compression): keep the unanswered tool round verbatim through mid-turn compaction
Preflight compaction can fire right after a tool round, before the model
has read its results. The protected-tail passes then treated that round
like any old output: pass 2 and the pressure pass (#61932) replaced its
results with one-line stubs, and when the newest row alone exceeded the
tail ceiling the cut landed at the end of the transcript and summarised
the round with its whole turn. The model then re-ran the call, side
effects included, or answered without the output.

_pending_tool_round finds the tool results the transcript ends with,
skipping one trailing /steer row (a steer is delivered after the newest
result, before the next API call). Both passes spare that round, and the
tail cut aligns from the row before the end so the group stays whole.
The one exception is a round that alone exceeds the tail's hard share
(20%) of the input budget, the window minus the output reservation: it
still gives way, as #61932 requires. _effective_input_window is
extracted from _compute_threshold_tokens so both use the same budget;
thresholds are unchanged.

(cherry picked from commit 9d74e22379cd7dc39636c522175772d0165db4ac)
2026-09-26 23:45:36 +05:30
Yuan Li
a5e8ad9f02 fix(agent): bound sustained summary-overload aborts so they cannot guarantee a session wipe
One overload abort preserves the transcript so a retry can still win (#115906).
But when every summary attempt keeps aborting under a sustained outage the
transcript only grows until the session exits compression_exhausted, which the
gateway answers with an auto-reset discarding the ENTIRE transcript — bounded
middle-window loss becomes total session loss, deferred.

After 3 consecutive overload aborts in one session the overload stops counting
as a terminal summary failure and compress() commits the deterministic fallback
(failure_class=summary_overload_degraded) — the same bounded degrade the
repeated-stall ladder already takes (#112420). A successful summary resets the
budget; abort_on_summary_failure=true still hard-aborts every attempt.

Fixes #123167

(cherry picked from commit 2941aadffa71a3623aee26dfb1109106b0555741)
2026-09-26 22:42:38 +05:30
kshitijk4poor
11c50f05d0 refactor(compression): Astra native-compaction gate reuses is_astra_model
The -900k alias fix hand-rolled a second copy of the Astra slug set and its
vendor-prefix normalization. agent/reasoning_effort.py::is_astra_model is the
documented single home for that set (picker, effort vocabulary and request
sanitizer already key off it), so the gate now calls it and a future Astra
alias stays a one-line edit. The gpt-5.6 marker check is back to main's exact
form.

Tests move into the existing parametrized Astra gate table, which checks both
the capability resolver and the per-request gate: -900k on official Codex OAuth
is eligible; -900k through a relay or on provider openai is not. Docs and the
config example no longer say "exact gpt-6-astra".
2026-09-26 19:27:41 +05:30
ethernet
b5d583c4ac feat(release): start the gates and the signed candidates together
Every gate and every candidate now needs only admit, so the signed macOS
and Windows builds and the docker image stop waiting behind the full CI
run. acceptance stays the one join: it still needs ci, docker and all six
candidates, so what can be promoted is unchanged.

The four gates that skipped under --skip-tests only because ci skipped
(nix, termux-checks, windows-live, install-e2e) and pm-bundle needed their
own condition. SKIPPED_BY requires the gate to observe 'skipped', so
dropping the edge alone would have left them running and blocked the
release instead of failing it.
2026-09-25 12:50:53 -04:00
ethernet
29f6f63f22 docs: describe the daily canary schedule and drop hindsight from built-in providers
canary-release.yml runs on a daily schedule and on manual dispatch, not on each push to main. Hindsight lives in plugin-catalog/, not plugins/memory/.
2026-09-24 15:25:18 -04:00
ethernet
dc11e3b3bc feat(release): add --skip-bundles and --skip-tests to stable releases
`release.py release` gains two flags. They can be used together.

--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.

--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.

The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.

The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.

A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:

- The next version was derived from it, so the next cut would reuse the
  version. It now takes the newer of the R2 head and the newest
  published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
  tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
  their publication pass, so a bundle-less release would re-advance
  every 15 minutes. The head is now the newer of the R2 head and the
  published release whose final tag binds the Docker stable alias
  digest.

`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.

Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:

- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]

Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
2026-09-24 13:31:33 -04:00
ethernet
845b61f2b8 fix(release): rerun failed stable runs on the failure event, drop the cron
Stable Release Publication ran every 15 minutes (96 runs a day, each
checking out full history, setting up node and buildx, logging into
Docker Hub, and taking the release-signing environment) only because the
sequencer held a failed run for a 15-minute backoff that the failure
event could never satisfy, so the cron was what actually retried.

Drop the backoff: the reconcile pass started by a failed Stable Release
reruns its failed jobs right away. MAX_ATTEMPTS burning, oldest-first
retry ordering, the attempt-entry check, and the needs_retarget repair
stay. The schedule trigger goes; workflow_run and workflow_dispatch
remain the recovery paths.

The shared stable-release concurrency group cannot deadlock: the rerun
waits as pending behind this job, and the sequencer only confirms the
new attempt is queued before it exits and frees the group.
2026-09-24 11:50:30 -04:00
ethernet
5c062d0cc3 docs: clarify Python updater range and test sandbox marker 2026-09-24 09:20:50 -04:00
ethernet
c70129205f Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 05:10:12 -04:00
ethernet
ca06a8dec9 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/bootstrap-installer/src-tauri/src/update.rs
2026-09-24 05:03:23 -04:00
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
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
ethernet
9824294c8b Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 04:45:34 -04: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
ethernet
cb7b18431a Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/src/app/settings/connections-registry.tsx
#	scripts/install.ps1
#	scripts/install.sh
#	tests/hermes_cli/test_update_autostash.py
2026-09-24 03:48:31 -04: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
ethernet
66d54ebf51 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.github/workflows/docker.yml
#	Dockerfile
#	apps/desktop/src/app/settings/about-settings.tsx
#	docker/stage2-hook.sh
2026-09-24 02:23:44 -04:00