Skip to content

feat(hermes-agent): native Hermes status, chat panel, and launcher provider#934

Open
Nomadcxx wants to merge 29 commits into
noctalia-dev:mainfrom
Nomadcxx:add-hermes-agent
Open

feat(hermes-agent): native Hermes status, chat panel, and launcher provider#934
Nomadcxx wants to merge 29 commits into
noctalia-dev:mainfrom
Nomadcxx:add-hermes-agent

Conversation

@Nomadcxx

@Nomadcxx Nomadcxx commented Jun 20, 2026

Copy link
Copy Markdown

hermes-agent adds a native Noctalia interface for Hermes: bar status, chat panel with streaming and tool-call approvals, session management, and a >hermes launcher command.

Bar widget

Traffic-light status: online, busy, needs-you, degraded, offline. Click the pill for a popup with model, provider, background jobs, MCP servers, and pending approvals.

Bar widget with summary popup

Chat panel

Send prompts, stream responses, approve tool calls inline, interrupt a running job, start or resume sessions.

Chat panel with streaming response and tool approval

Launcher

Type >hermes in the Noctalia launcher. Opens the panel, starts a session, resumes the latest, or fires a one-shot prompt.

Settings

Bridge host and port, state file path, Hermes home, poll interval, default provider and model, auto-start bridge, hide-when-idle, pin panel, show tool activity.

Settings page

Bridge

scripts/hermes_bridge.py runs locally on 127.0.0.1 with HTTP endpoints for health, state, session, prompt, interrupt, approvals, and one-shot. QML surfaces call the bridge and watch a JSON state file.

On first start the bridge generates a bearer token (secrets.token_urlsafe(32)), saves it to ~/.cache/noctalia-hermes/bridge.token (0o600), and checks every request with secrets.compare_digest(). Bodies cap at 1 MB. State files are 0o600. Errors return a generic code only.

Overlap with #898 and #931

#898 (hermes-status) is a read-only traffic light. This bar widget matches that indicator and adds the summary popup, panel, approvals, interrupts, sessions, and launcher. Status-only users can ignore the panel.

#931 (hermes-ssh-chat) is an SSH terminal for remote Hermes. This plugin uses a local bridge. Remote Hermes: wrap hermesCommand in SSH, or run the bridge on the remote host and set bridgeHost.

#898 and #931 both work. If the registry wants one Hermes plugin instead of three, this one has the wider surface. I'll leave that to maintainers.

Manifest id matches folder, preview.png 960×540, i18n via pluginApi?.tr(), N* widgets, Logger, Settings.qml saveSettings(). CI passed manifest-check and code-quality.

MIT

…ovider

Adds a Noctalia plugin providing bar status, chat panel, tool-call
approvals, interrupts, session management, and a >hermes launcher
command for Hermes.

- Bar widget: traffic-light status with summary popup
- Panel: streaming chat, inline approvals, interrupt, sessions
- Launcher: >hermes one-shot prompts and session control
- Settings: bridge config, poll interval, defaults, display options
- Bridge: local authenticated HTTP bridge with token auth, 1MB body
  limit, 0o600 state file permissions

Supersedes noctalia-dev#898 (read-only status) and covers noctalia-dev#931 use case (remote
Hermes via bridgeHost setting).
@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/BarWidget.qml

  • (H) Line 168: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1
  • (H) Line 222: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1

File: hermes-agent/Panel.qml

  • (H) Line 153: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1

File: hermes-agent/components/SummaryPopup.qml

  • (H) Line 187: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1

Nomadcxx added 2 commits June 20, 2026 15:36
CI code-quality review flagged hardcoded border.width: 1 values.
Replaced with Style.borderS per AGENTS.md Style singleton requirement.
…rst run

Bridge now auto-detects HERMES_HOME (env, ~/.hermes, ~/.config/hermes,
~/.local/share/hermes), the hermes binary (PATH, ~/.local/bin/hermes,
~/.hermes/bin/hermes), gateway status from gateway_state.json, and the
default model/provider from config.yaml.

New /detect endpoint returns all detected values. QML auto-configures
on first run (empty pluginSettings) and saves detected paths and model
to settings. Settings UI collapses advanced fields (bridge host/port,
state file, hermes home, hermes command) behind a toggle. Users only
see model selection by default.
@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/BarWidget.qml

  • (H) Line 168: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1
  • (H) Line 222: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1

File: hermes-agent/Panel.qml

  • (H) Line 153: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1

File: hermes-agent/components/SummaryPopup.qml

  • (H) Line 187: Do not use hardcoded values, always prefer to use the Style singleton instead
+            border.width: 1

@Nomadcxx

Copy link
Copy Markdown
Author

The border.width: 1 findings in the latest bot review are stale. Fixed in commit 8670c34 — all border.width values now use Style.borderS or Style.capsuleBorderWidth. Verified on the branch:

hermes-agent/BarWidget.qml:122:    border.width: Style.capsuleBorderWidth
hermes-agent/BarWidget.qml:168:            border.width: Style.borderS
hermes-agent/BarWidget.qml:222:            border.width: Style.borderS
hermes-agent/Panel.qml:153:            border.width: Style.borderS
hermes-agent/components/SummaryPopup.qml:138:    border.width: Style.borderS
hermes-agent/components/SummaryPopup.qml:187:            border.width: Style.borderS

…uto-start

Four bugs prevented the plugin from working on a fresh install:

1. autoConfigure() never ran because the first-run check tested
   Object.keys(pluginSettings).length === 0, but Noctalia populates
   pluginSettings with manifest defaults before Component.onCompleted
   fires. Replaced with an explicit 'configured' flag in defaultSettings.

2. Race condition: autoConfigure() ran immediately after ensureBridge()
   in Component.onCompleted, but the bridge process was not listening
   yet. Now autoConfigure() is chained after the bridge health check
   succeeds, with a retry timer polling /health up to 20 times.

3. Bridge auto-detect comparison failed because QML passes expanded
   paths (e.g. /home/user/.hermes) but run_server() compared against
   the literal '~/.hermes'. Now checks both tilde and expanded form.

4. No handling for when the Hermes gateway is not running. Added
   /gateway/start and /gateway/status endpoints to the bridge, a
   'Start gateway' action in the bar context menu and summary popup,
   and an autoStartGateway setting (default true) that starts the
   gateway during autoConfigure() if it is offline/stopped/unknown.
   The bridge tries 'hermes gateway start' (systemd/launchd) first,
   falling back to a detached 'hermes gateway run' process.
@Nomadcxx

Copy link
Copy Markdown
Author

Update: Auto-detect and gateway auto-start fixes

Pushed commit ecd14d55 fixing four bugs that prevented the plugin from working on a fresh install:

1. First-run detection never triggered

autoConfigure() checked Object.keys(pluginSettings).length === 0, but Noctalia populates pluginSettings with manifest defaults before Component.onCompleted fires — so the check was always false. Replaced with an explicit configured flag in defaultSettings.

2. Race condition: bridge not ready when autoConfigure ran

autoConfigure() was called immediately after ensureBridge() in Component.onCompleted, but the bridge process wasn't listening yet. Now autoConfigure() is chained after the bridge health check succeeds, with a retry timer polling /health up to 20 times (500ms interval).

3. Bridge auto-detect path comparison failed

QML passes expanded paths (e.g. /home/user/.hermes) but run_server() compared against the literal '~/.hermes'. Now checks both tilde and expanded form via os.path.expanduser().

4. No handling for gateway not running

Added gateway lifecycle support:

  • /gateway/start endpoint: tries hermes gateway start (systemd/launchd), falls back to detached hermes gateway run process
  • /gateway/status endpoint: returns detailed gateway status
  • Bar context menu: "Start gateway" action appears when status is offline/unknown
  • Summary popup: warning box with start button when gateway is down
  • autoStartGateway setting (default true): auto-starts gateway during autoConfigure() if offline/stopped/unknown
  • Settings page: toggle for auto-start gateway in advanced settings

CI: code-quality ✅ manifest-check ✅

Nomadcxx added 7 commits June 20, 2026 12:33
…teway start

Race condition: on fresh install, the bridge creates the token file
when it starts, but the FileView may not have reloaded it by the time
autoConfigure() runs. This caused /gateway/start to fail silently
with 401 (no token in header).

Fix: gate refreshState() and autoConfigure() behind the token
FileView's onLoaded callback via a bridgeOnlinePending flag. When the
bridge comes online, onBridgeOnline() sets the flag and reloads the
token file. The onLoaded callback then runs refreshState() and
autoConfigure() with the token guaranteed to be loaded.
…n NButton

NButton does not have a 'flat' property. The QML engine logged
'Cannot assign to non-existent property flat' at Settings.qml:116
each time the settings popup opened, which caused the Loader to
fail rendering the entire Settings.qml content — resulting in an
empty popup showing only the wrapper header and Apply button.

Replaced flat: true with outlined: true, which gives the intended
less-prominent appearance for the Advanced settings toggle.
…ng call sites

Three pluginApi accesses were missing the ?. operator:
- Main.qml: root.pluginApi.openPanel inside withCurrentScreen callback
- BarWidget.qml: pluginApi.manifest in context menu settings action
- BarWidget.qml: pluginApi.manifest in SummaryPopup onSettings handler
@FelipeMayerDev

Copy link
Copy Markdown

Hi @nomadx 👋 — great plugin, we've been running it in production here.

We forked it to add a client-only mode for Hermes running on a remote server (no local bridge, no hermes binary on the client). The key changes:

  • Main.qml: clientOnlyMode toggle — skips subprocess spawn, reads token from settings, polls /state over HTTP instead of watching a local file. HTTP state poll interval adapts: 1.5 s while a session is running, slow when idle.
  • BarWidget.qml: fall back to idle when hermes.status = "unknown" but gateway is running (avoids a misleading grey pill on a fresh bridge with no session yet).
  • Settings.qml + manifest.json: two new settings (clientOnlyMode, bridgeTokenManual).
  • scripts/hermes-bridge-serve.sh: server-side helper that starts the bridge bound to 127.0.0.1 and prints the token + the SSH tunnel command.
  • Docs: setup guide + troubleshooting for the SSH-tunnel flow.

The bridge protocol is unchanged — everything goes through the existing /health, /state, and POST endpoints you already ship.

Our fork + commits: https://github.com/FelipeMayerDev/noctalia-custom-plugins/commits/main/hermes-agent/

Happy to open a PR against your branch (add-hermes-agent) if that's easier. Or you can cherry-pick. Whatever workflow suits you best.

Nomadcxx and others added 2 commits June 21, 2026 13:30
Client-only mode lets users run the plugin without a local Hermes
installation. The bridge runs on a remote server, reached via SSH
tunnel. Adds statePollTimer for HTTP polling, idle fallback in bar
widget when gateway is running, and a server helper script.

Co-authored-by: FelipeMayerDev <65250731+FelipeMayerDev@users.noreply.github.com>
@Nomadcxx

Copy link
Copy Markdown
Author

Thanks @FelipeMayerDev, cherry-picked your client-only mode into the PR in commit f8ea895. All seven plugin files are in, bridge protocol untouched.

A few things I had to change for the upstream contribution rules: reverted the manifest repository URL back to the noctalia-plugins registry, added your name to the author field, and bumped the version to 1.1.0. Left your fork-level AGENTS.md and root README out since those are fork-specific.

The idle-fallback fix in BarWidget was a real gap I had not caught. Good catch.

Co-authored-by trailer is on the commit. Thanks for the clean diff.

Nomadcxx and others added 6 commits June 21, 2026 14:46
…es for client-only mode

- Test button in Settings pings /health on configured host/port/token
- Shows Connected, Unreachable, or Wrong token result
- ensureBridge() now reports remote bridge address when unreachable
- statePollTimer calls ensureBridge() instead of refreshState() for health check
- onClientOnlyModeChanged calls ensureBridge() to connect after toggle
- getJson/postJson distinguish status 0 (network) from 403 (auth) from HTTP errors

Co-authored-by: FelipeMayerDev <65250731+FelipeMayerDev@users.noreply.github.com>
…oints

- Delete SessionHeader.qml, ToolEventRow.qml (zero references)
- Delete hermes_attention.py, hermes_status_check.py, hermes_status_hook.py (not called)
- Add /gateway/start and /gateway/stop bridge endpoints (hermes gateway start/stop)
- Add stopGateway() to Main.qml, call on client-only mode switch
- Gate start-gateway context menu behind !clientOnlyMode
- Hide autoStartGateway toggle in settings when client-only mode active

Co-authored-by: FelipeMayerDev <65250731+FelipeMayerDev@users.noreply.github.com>
…idgeToken binding

Two bugs in client-only mode toggle cycle:

1. bridgeToken binding break: tokenFileView.onLoaded imperatively assigned
   root.bridgeToken, destroying the QML binding. After toggling clientOnlyMode,
   bridgeToken stayed stuck on the local token → 403 from remote bridge.
   Fix: make bridgeToken readonly, add separate bridgeTokenFromFile property.

2. Toggle cycle: bridgeHost/bridgePort were single settings for both modes.
   Toggling clientOnlyMode OFF left bridgeHost pointing at remote IP, so normal
   mode sent local token to remote bridge → 403. Also startBridge() tried to
   bind local bridge on remote IP.
   Fix: mode-dependent bindings — 127.0.0.1:19777 in normal mode, configured
   remote host/port in client-only mode.

Also removed stopGateway() from onClientOnlyModeChanged (was hitting remote
bridge), added maybeStartGateway() for clean local gateway restart on toggle
back to normal mode, and added diagnostic logging.
…esAvatar, fix trap + setBridgeError deep-copy

- Merge getJson/postJson into shared requestJson() (-25 lines)
- Extract HermesAvatar.qml component, replace 4 avatar duplications (-60 lines)
- Fix postJson log typo (was logging 'getJson')
- Remove dead displayText branch (session.running check was no-op)
- Fix SummaryPopup indentation
- Deep-copy state in setBridgeError for QML change detection
- Move trap before bridge start + add EXIT signal
- Detect bridge startup failure during token wait loop

net: -63 lines, 1 new file
@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@Nomadcxx

Copy link
Copy Markdown
Author

What changed since the first version

Bridge toggle fix

Toggling client-only mode off then on again gave 403. Two causes:

  1. bridgeToken was mutable. tokenFileView.onLoaded overwrote it with the local token, breaking the QML binding. Switching to client-only left the stale local token. Now readonly with a separate bridgeTokenFromFile property.

  2. bridgeHost/bridgePort were single settings for both modes. Toggling off left the remote host in settings, so the plugin sent the local token to the remote bridge. Now mode-dependent: normal mode always uses 127.0.0.1:19777, client-only uses configured remote.

Client-only mode

Test Connection button in settings validates the remote bridge token. Error messages distinguish connection failures, auth failures (403), and other HTTP errors.

Bridge resilience

Auto-restarts on health check failure. The serve script detects startup failures during the token wait loop and exits instead of hanging 10 seconds. Cleanup trap covers EXIT and fires before the bridge starts.

Code cleanup

  • Merged getJson/postJson into requestJson(method, path, payload, callback)
  • Extracted HermesAvatar.qml, replacing 4 duplicated avatar blocks
  • setBridgeError deep-copies state so QML change detection fires
  • Removed dead startGateway signal from SummaryPopup and BarWidget
  • Removed dead displayText branch where session.running returned the same value
  • Fixed postJson logging "getJson" in error messages
  • Fixed SummaryPopup GridLayout indentation

Net: -63 lines, 1 new file.

@Nomadcxx

Nomadcxx commented Jun 22, 2026

Copy link
Copy Markdown
Author

github-actions bot (comment):
File: hermes-agent/components/HermesAvatar.qml
Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!

The applyUiScale: false is intentional. The icon size comes from the parent (e.g. root.barFontSize in BarWidget, Style.fontSizeXL in Panel), which already accounts for UI scale. Applying it twice would double-scale the icon.

- saveSettings now calls setModel so popup Apply button works
- New Session clears messages/events/approval in bridge state
- Added Reset button to Panel (calls createSession for server-side clear)
- Fixed HermesAvatar.qml deployment to installed plugin
- Removed unused bridgeOnline reference from createSession log
- Added clearChat function for local state reset
@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@Nomadcxx

Copy link
Copy Markdown
Author

Update: 2026-06-23

Bug fixes and a model-picker overhaul since the last push.

Bugs fixed

Model apply. The popup Apply button only saved plugin settings, never called setModel. It does both now. The internal Apply Model button and the popup's Apply produce the same result.

New Session. The bridge created a new Hermes RPC session but kept old messages and events in state. The panel looked unchanged. Fixed in the bridge: session.create now clears messages, events, and approval state.

Reset. Added a Reset button next to New Session in the chat panel. Clears messages without a server roundtrip. Disabled when the pane is empty.

Toggle cycle. Switching between normal mode and client-only mode broke bridge contact because bridgeHost and bridgePort were single settings shared by both modes. Now mode-dependent: normal mode always hits 127.0.0.1:19777, client-only mode uses configured host/port.

Bridge token binding. A mutable bridgeToken property lost its binding when the token file loaded, sending the wrong token on mode switch. Made it readonly with a separate bridgeTokenFromFile property.

Provider list

The Settings dropdown now merges three sources: provider config models from config.yaml, the Hermes model catalog cache, and models.json. This matches what hermes --tui shows. You see every provider and model Hermes knows about. Whether a specific combination works depends on your API credentials.

HermesAvatar component. Extracted from four duplicate avatar blocks across BarWidget, Panel, and SummaryPopup. Fixes a Main.qml load failure when the file was missing from installed dirs.

Commits: f42badb e541ceb c830f38 2ff09b3 7f658c3

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

… fix

- Add GET /sessions endpoint dispatching session.list RPC
- SessionPopup.qml: scrollable popup listing 10 recent sessions
- resumeSession loads messages from SessionDB directly for instant feedback
- Prompt auto-creates gateway session, preserves loaded history
- Debounced Timer scrolls chat to bottom on state changes
@Nomadcxx

Copy link
Copy Markdown
Author

Update: session picker feature

Latest commits add a session history picker to the chat panel header.

What's new:

  • History button (clock icon) in the chat panel header opens a popup listing 10 recent sessions
  • Clicking a session loads its full message history from the Hermes SessionDB
  • Prompts sent from a resumed session continue the conversation — the agent responds with context
  • Chat auto-scrolls to the latest message on state changes

How it works:

The bridge reads messages directly from Hermes' SQLite state.db for instant feedback. On the first prompt after resume, session.create establishes a proper gateway session. Old messages are preserved after creation so the full history stays visible.

Repo differences to note:

The standalone repo (Nomadcxx/noctalia-hermes-agent) has its own registry.json at root listing just this plugin. This PR branch uses the monorepo's central registry.json. The manifest.json repository field also differs — standalone points to this plugin's repo, PR points to the monorepo. Keep these separate when making changes.

Commits: 2ee0505 (PR), 1d4433f (standalone dev)

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/Panel.qml

  • (L) Line 180: When it comes to translations there is no need for fallback values. From: pluginApi?.tr("example") || "value". To: pluginApi?.tr("example")
+          tooltipText: pluginApi?.tr("panel.sessions") || "Sessions"

File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

File: hermes-agent/components/SessionPopup.qml

  • (L) Line 45: When it comes to translations there is no need for fallback values. From: pluginApi?.tr("example") || "value". To: pluginApi?.tr("example")
+        text: pluginApi?.tr("panel.sessions") || "Sessions"
  • (H) Line 73: Do not use hardcoded values, always prefer to use the Style singleton instead
+        spacing: 2
  • (L) Line 127: When it comes to translations there is no need for fallback values. From: pluginApi?.tr("example") || "value". To: pluginApi?.tr("example")
+            text: pluginApi?.tr("panel.noSessions") || "No recent sessions"

@Nomadcxx

Copy link
Copy Markdown
Author

File: hermes-agent/components/HermesAvatar.qml Line 31applyUiScale: false

This is intentional. The parent passes an already-scaled icon size to the avatar component. Setting applyUiScale: false prevents double-scaling. Same explanation as earlier review round (comment 4770033556).

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Code Quality Review


File: hermes-agent/components/HermesAvatar.qml

  • (L) Line 31: The applyUiScale: false would make it so that the component does not support ui scaling. Always check if this is the correct behaviour you want when changing the ui scale!
+    applyUiScale: false

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants