Commit Graph

4062 Commits

Author SHA1 Message Date
ethernet
15aced7a24 docs(install): installer status lines, install.log and verbose switches 2026-09-24 14:23:48 -04:00
ethernet
d3f2872258 docs(i18n): note KittenTTS is unavailable on managed Python 3.14
The English TTS page already explains that the kittentts extra is gated
off on the managed 3.14 interpreter (misaki requires <3.13); the zh-Hans
page still presented KittenTTS as installable.
2026-09-24 14:08:22 -04:00
ethernet
25a22628ea docs(docker): document the full Chromium in every image
The Dockerfile now stages only PM's pinned full Chromium (no headless
shell) for both the slim and -desktop variants. docker.md still listed a
headless shell, and bot-screen.md claimed the official image had no
headed browser and that the -desktop build added Playwright's headed
Chromium. Correct both, note the size cost of the full build on slim
images, and describe the browser pick order browser.executable() uses
(managed first, system first only for non-root under restricted userns).
2026-09-24 14:08:22 -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
0f65d698d0 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 13:10:14 -04:00
kshitijk4poor
c89265299a docs(wecom): native streaming follows the global streaming.enabled switch (#53697) 2026-09-24 22:31:37 +05:30
ethernet
72df5aa60e feat(docker): install opt-in dependencies into PM generations on the volume
The image refused every lazy install (HERMES_DISABLE_LAZY_INSTALLS=1), so
edge-tts and the other opt-in SDKs could never be installed at runtime. PM
never writes the sealed /opt/hermes/.venv: it builds a generation under
$HERMES_HOME/installs and commits it in facts.json there, which already
survives container recreates and image updates. Drop the refusal.

Surviving updates means a new image boots under a selection resolved against
the previous image's lock. refresh_dependencies() re-resolves the recorded
extras and plugins against the current inputs; if that fails (offline), it
deselects the generation so the image's own environment boots, keeping the
extras recorded for the next boot or install. stage2 runs it as hermes before
any service starts, then collects generations nothing selects any more, since
nothing else collects them automatically and each one is a full venv.
2026-09-24 12:40:17 -04:00
ethernet
689ec30f34 fix(docker): provision ffmpeg and ripgrep through PM
The lock pins both as required packages, but the image took them from apt,
so `hermes pm doctor` reported them missing and `hermes doctor` escalated to
"install out of sync ... rebuild the artifact" on every container. Ensure them
through PM like uv/chromium/npm, drop the apt packages, and link ffmpeg,
ffprobe (shipped beside it) and rg into /usr/local/bin for `docker exec`.
2026-09-24 12:30:24 -04:00
ethernet
ed6e37f9c1 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 11:58:45 -04:00
brooklyn!
177f275b77 fix(plugins): route inject_message to the TUI session_key queue
Ink TUI and desktop never registered an inject host, and sharing
set_gateway_message_injector with a live messaging gateway would let
the last writer win. A separate host queues the reported session_key
onto that session's prompt queue and leaves other keys for the gateway.

Fixes #87412
2026-09-24 10:56:54 -05:00
ethernet
43ebb6f1ed docs(env): document the internal bridge variables
HERMES_DATA_DIR_SUFFIX, HERMES_REPO_URL, HERMES_UPDATE_STATUS_FILE and
HERMES_UPDATE_UI_ACTIVE are set by Hermes' own bundles, installers and
update shim, not by users, but only the suffix was documented and it
read as a user knob. Add an "Internal bridge variables" table that
says who sets each one and why it cannot live in config, and move the
suffix row there.
2026-09-24 11:50:30 -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
7d67c3f125 docs(tts): code-format the KittenTTS Python bound so MDX does not read it as a tag
MDX parsed the bare <3.13 as a JSX element and failed the Docusaurus build.
2026-09-24 11:12:27 -04:00
ethernet
5c062d0cc3 docs: clarify Python updater range and test sandbox marker 2026-09-24 09:20:50 -04:00
ethernet
5da731b39a fix: ask before installing missing extras from an interactive terminal 2026-09-24 09:20:50 -04:00
ethernet
8eb20a50ae Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	agent/learning_graph.py
#	hermes_cli/backup.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_doctor_journal_modes.py
#	tests/hermes_cli/test_sessions_set_journal_mode.py
2026-09-24 08:51:52 -04:00
kshitijk4poor
9c0e223d96 fix(sessions): let set-journal-mode --force waive only a failed holder scan
Retiring the win32 gate left `--force` with no reader, so on a Windows host
where the Restart Manager cannot start a session (restricted/service context)
the fail-closed `(-1, scan failed)` sentinel made `set-journal-mode`
permanently unrunnable, while optimize/optimize-storage/prune kept a working
override. `--force` now drops only pid <= 0 sentinel entries — a process the
scan actually found is still refused — and the help text says exactly that.

The holder test parametrised over `force` now asserts something real: force
plus a live holder is refused, force plus a failed scan proceeds (and without
force the failed scan is refused). Rewrite the user-guide paragraph that still
described a POSIX-only scan and a no-scan Windows path. Drop the dead
`import time`. The doctor holder test compares the child-reported pid instead
of `Popen.pid`, which is the venv launcher on Windows, so un-skipping it there
does not assert a pid equality that cannot hold.
2026-09-24 18:08:24 +05:30
rodricksz4h5
c633cd87c5 fix(memory): key the journey card lookups to the fingerprinted node id
Two places map a node id back to its memory card by rebuilding the bare
`memory:<source>:<index>` string: the timeline chart rows and the desktop
starmap's tooltip/body map. With the fingerprint in the id both missed
every card, so a memory row rendered an empty body.

Both now key the card under whichever shapes apply, so an imported or
pre-fingerprint graph keeps working. The CLI help and the memory doc now
describe the id as `journey list` prints it.

(cherry picked from commit fa0c984c5d7d7ad0820fa2f2bde088e532f4e897)
2026-09-24 18:01:46 +05:30
kshitijk4poor
b24b7b725c fix(import): refuse a damaged backup archive before touching the home
`hermes import` only checked the central directory (`is_zipfile`,
`namelist`), so an archive with one member whose deflate stream or CRC is
rotten passed validation and blew up mid-restore with a zlib.error
traceback -- after config.yaml and everything before the bad member had
already been replaced, with later members never written (#121258).

Add a pre-flight pass in run_import that streams every member through
1 MiB reads (zipfile verifies the CRC at EOF) and collects every
BadZipFile / zlib.error / EOFError. If any member is damaged the command
prints a capped list and returns 1 with the home untouched. Stdlib
`ZipFile.testzip()` is deliberately not used: it lets zlib.error escape
and names at most the first bad member.

Widen the per-member catch from the previous commit with EOFError so a
member that rots between the two passes still becomes a "skipped" warning
+ `Import incomplete` / exit 1 instead of a traceback. Rework that
commit's test to drive the per-member path (the archive now never reaches
it with a corrupt member), and let `_break_member` serve the pre-flight
read before failing the restore's own read.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
2026-09-24 18:00:44 +05:30
John Paul Soliva
4194919f5d fix(import): exit 1 and say "incomplete" when archive members were skipped
`_import_members` catches the PermissionError/OSError of each member it
cannot publish and records it in `errors`. That includes the state.db the
live-safe restore refuses because a gateway or dashboard still holds it
(#100960, #110179). `run_import` printed those under "Warnings (N files
skipped)", then printed "Done. Your Hermes configuration has been
restored." and returned None. `cmd_import` did not forward a return value,
so the shell status was 0. A script chained on `hermes import --force`
carried on over a partial restore, and the dashboard's import action
showed the green "done" badge. For the refused state.db case, the user's
sessions were never restored.

`run_import` now returns 1 when `errors` is non-empty. It words both the
summary header and the final line as "Import incomplete", and `cmd_import`
forwards the return code, the same contract `hermes backup` has for an
incomplete archive. Runtime files the import deliberately keeps
(gateway.pid, SQLite sidecars) and the older-backup session warning stay
warnings. The gateway revive still runs, so the files that did land come
up as before.

Measured on main with a fresh HERMES_HOME through `main()`: a 3-file
backup with one read-only target directory, and a refused state.db (live
DB left at 3 sessions / 12 messages). Both used to exit 0 after "Done ...
restored". Both now exit 1 after "Import incomplete: 1 file(s) were not
restored". A clean import still exits 0 and prints "Done".

(cherry picked from commit a0f808ec9f868128f4174e4816e095ac17f18d28)
2026-09-24 18:00:44 +05:30
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
ethernet
43c105d5ba Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 07:39:33 -04:00
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
ethernet
41934d5015 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 06:21:50 -04:00
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
ethernet
87aa0b55de Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	hermes_cli/__init__.py
#	hermes_cli/profile_distribution.py
#	pyproject.toml
#	uv.lock
2026-09-24 06:14:42 -04:00
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
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
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
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