Commit Graph

28266 Commits

Author SHA1 Message Date
David Dudok de Wit 72fc5ab7ca fix(desktop): use gateway terminology in connection UI 2026-08-18 22:03:25 -07:00
David Dudok de Wit 3095ac54db docs(desktop): clarify connection settings copy 2026-08-18 22:03:25 -07:00
David Dudok de Wit 2228bc7d88 feat(desktop): remember the last used source 2026-08-18 22:03:25 -07:00
David Dudok de Wit ccd30f955c feat(desktop): distinguish sources from profiles 2026-08-18 22:03:25 -07:00
David Dudok de Wit 0ab0ed54ff feat(desktop): clarify active and default connections 2026-08-18 22:03:25 -07:00
David Dudok de Wit 4ea516b8d4 docs(desktop): explain multi-source session scoping 2026-08-18 22:03:25 -07:00
David Dudok de Wit fa73b45d34 fix(desktop): isolate state across source switches 2026-08-18 22:03:25 -07:00
David Dudok de Wit 0ca7443824 feat(desktop): switch sources from the Sessions rail 2026-08-18 22:03:25 -07:00
David Dudok de Wit 7245b022db feat(desktop): route REST through registered sources 2026-08-18 22:03:25 -07:00
amanning3390 8e5f55f83b feat(hermes-bots): add a collapsible group activity view
Bot Mode group rooms only showed a single "is thinking…" line while bots
ran, and nothing after a turn settled or failed — no way to see what the
room did without reading the whole transcript.

- Runtime-only, bounded activity feed per room (GROUP_ACTIVITY_LIMIT),
  recording truthful turn events: queued, working, replied, passed,
  timed-out, failed, cancelled, settled, delivered.
- Every event is tagged with the room epoch it belongs to; the view shows
  only the CURRENT run, so a superseding send (or a rename that re-keys
  the room) can never surface stale activity.
- Quiet disclosure in the room header: collapsed by default, the collapsed
  row shows the latest event summary; expanding lists the current run's
  events newest-first with per-state glyphs and tones.
- Never persisted and never hydrated — the transcript stays the only
  durable record, so activity cannot be replayed as history.

Tests: 8 new behavioral + source-contract cases in
tests/group-activity.test.mjs (settled arc, failed turn, supersede/cancel,
epoch filtering, bounded feed, runtime-only guarantee, labels, disclosure
a11y contract).
2026-08-18 22:03:02 -07:00
pierrenode 589bee99cf fix(bot-mode): never resubmit into a still-running stranded group member
runGroupChatRounds' responder selection had no awareness of the stranded/
harvest state the previous round's harvest pass just confirmed. A member
whose turn timed out (marked stranded) but is STILL genuinely running
could be re-selected as a responder in a later round of the same
invocation — resolveGroupResponders has no busy filter, and a member's
watermark is bumped past the stranded timeout regardless of outcome, so a
fresh delta re-qualifies them.

Re-selecting them fires another prompt.submit into their live session.
tui_gateway's _handle_busy_submit treats that as a normal busy mid-turn
prompt: by default it either redirects the live turn in place or, for
older agents, hard-interrupts it and queues the new text as the next
turn. Either way the member's original in-flight work — exactly what the
stranded/harvest mechanism exists to protect — gets abandoned or killed,
undermining the "never lost, just late" guarantee.

Filter responders against the room's current stranded map (freshly
confirmed by this round's own harvest pass) before selecting who speaks.
A member with a live stranded marker is skipped; the next harvest pass
picks their reply up once it actually lands.

Added a regression test exercising runGroupChatRounds end-to-end with a
member confirmed still-busy: without the guard the round loop resubmits
into their session (asserted via prompt.submit call count); with the
guard it never does, and the marker survives untouched. Mutation-verified:
temporarily reverted the filter and confirmed the new test fails
(2 !== 0) fast, without a real wall-clock wait.

Full hermes-bots plugin suite: 246/246 pass (47 files). node --check
clean on both changed files.
2026-08-18 22:02:32 -07:00
Teknium a6bada232c feat(mcp): per-server oauth.user_agent for token-endpoint requests (#75576)
Some authorization servers and WAFs reject httpx's default User-Agent on
the OAuth token endpoint. mcp_servers.<name>.oauth.user_agent now stamps a
custom User-Agent onto the two token-endpoint requests (authorization-code
exchange and refresh) on both provider construction paths. Opt-in,
per-server, token requests only — never MCP traffic or discovery, and no
other headers are configurable. Empty/null/non-string values are ignored.

Completes the second half of #75576 (the CIMD half landed via #89566).
2026-08-18 21:57:53 -07:00
Teknium d440891204 test(desktop): update canonical-chat-identity expectation for salvaged PR #89031 2026-08-18 21:52:41 -07:00
marketing2981 ed47d41dc9 fix(desktop): bot navigation no longer forces the all-profiles sidebar on
Every host.openSession call in the Bot Mode plugin omitted
keepAllProfilesScope, so the SDK applied its default and flipped
$showAllProfiles back on whenever the target session belonged to a
different profile than the live gateway (sdk/index.ts:
options.keepAllProfilesScope !== false => setShowAllProfiles(true)).

For anyone running more than one profile this silently undid the sidebar
profile filter: narrow Sessions to one profile, click any other bot, and
the unified all-profiles list came back.

Bot navigation is an explicit context switch into that bot's profile, so
pass keepAllProfilesScope: false at every openSession call site (4 on
current main after the plugin.js refactor consolidated the original 7).

Salvaged from PR #89031 onto current main; includes contributor mapping.
2026-08-18 21:52:41 -07:00
Teknium af3373f7c8 test: follow-up for salvaged PR #89020 — focused-highlight contract includes the !activeGroup guard 2026-08-18 21:52:11 -07:00
Shawn Wang 80f080b6ed fix(bot-mode): highlight selected group chat
Signed-off-by: Shawn Wang <32839114+enwaiax@users.noreply.github.com>
2026-08-18 21:52:11 -07:00
chelsealong 3809f740c6 fix(bot-mode): reseed $selectedBot on register so re-enabling never leaves it stale
nanostores' .listen() never replays the current value the way .subscribe()
does, so the $focusedBotProfile listener in register() only kept
$selectedBot current from the moment it was attached. A disable -> profile
switch -> re-enable cycle (Settings > Plugins) left $selectedBot pointed at
whichever bot was active before the plugin was disabled, so the roster
highlight fallback and Routines scoping could start from a stale bot.

Extract the sync into bindProfileSync(), which reseeds $selectedBot from
the profile store's current value before attaching the listener. This runs
on every register() call, so re-enabling the plugin always starts in sync.

Salvaged from PR #89637 (the pane-precedence portion was superseded on main
by the $focusedBotProfile design; this residual reseed gap remained).
Regression test mimics real nanostores get/listen semantics and fails
without the reseed.

Fixes-residual-of: #89625
2026-08-18 21:51:36 -07:00
Jaime Henriques 35078e849b docs: surface Bot Mode in docs landing paths 2026-08-18 21:51:09 -07:00
David Dudok de Wit 00e9a32454 fix(desktop): keep Bot Mode routines docked 2026-08-18 21:50:56 -07:00
Jay 104ec97595 fix(bot-mode): group room preview shows the bot handle, not @default
`botHandle()` exists so that, per its own comment, "the word 'default'
never surfaces in the UI" — it presents the primary profile as `hermes`.
The roster rows, mention resolution and the group-chat prompt all route
through it. Two preview paths did not, and rendered the raw profile name:

- `GroupRow`'s room preview line built `@${last.from?.name}`, so a group
  room read `@default: …` while the bot answers to `@hermes`.
- `previewKind()` returned the raw captured name from the bot-to-bot
  delivery prefix, so the `🤖 @<name>` badge and its tooltip could show
  `@default` too.

The mismatch is presentation-only, but it reads as a routing bug: the
room says the message came from `@default` while `@default` is not a
handle the mention resolver accepts, so users reasonably conclude
bot-to-bot addressing is broken when it is working correctly.

Both paths now map through `botHandle()`. `GroupRow` passes the matching
member so a bot with a custom handle keeps it; `previewKind` maps the
lowercased sender name, which leaves every non-primary profile unchanged.

Tests: the primary profile resolves to `hermes` and a named profile keeps
its own handle (behavioural, in the existing previewKind suite), plus a
source-shape assertion for the render path matching that file's
convention. Both new assertions fail against the pre-fix source.

Fixes #89484
2026-08-18 21:50:31 -07:00
Teknium cb0fd836ac feat: Bot Mode blob avatars gain 4 new silhouettes via blobatar 2.0.0
blobatar 0.2.0 -> 2.0.0 (gen2). Ten silhouettes instead of six:
capsule, triangle, hexagon and droplet join round, organic, boxy,
nub, cloud and sun. BLOB_KIND_TRAIT repinned to gen2 band centers
(empirically verified against the published package: every pinned
value resolves to its named silhouette across seeds). The avatar
picker derives from BLOB_KINDS, so the new shapes appear there
automatically. Note: gen2 remaps most unpinned seed->face mappings
by design (upstream generation change).
2026-08-18 21:45:29 -07:00
hermes-seaeye[bot] 395c70d616 fmt(js): npm run fix on merge (#89703)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-19 04:05:24 +00:00
ethernet 0b879298a7 feat(desktop): browser-bar controls for the address and the page
The browser bar gets an open-in-external-browser glyph that opens
the tab's live address. The address field gets a copy control on its
right edge, with the same pre-faded inline appearance as the
code-block copy button. Copy always takes the address the field
shows: on a remote gateway, that is the reach-resolved address. The
page verbs (copy URL, open externally, console, DevTools) live here
and not in the guest context menu, which keeps the node-scoped
tools.
2026-08-18 23:53:07 -04:00
ethernet 1314a539c2 refactor(desktop): custom translated context menus across the app
One renderer coordinator owns every right-click and replaces the
native Electron menus. Menus are assembled from what the click
landed on:

- Links and images get open/copy/save sections; chat links add the
  reach-aware resolved-URL copy on remote gateways.
- Editables get spell-check suggestions (async-appended when
  Chromium's facts arrive from main), cut/copy/paste, and select
  all. Cut and copy need a selection; paste needs a non-empty
  clipboard; select all needs field content. The verbs show their
  accelerators instead of icons and dispatch a frame after the menu
  closes, so the radix focus trap cannot steal the target. Select
  all runs renderer-side, scoped to the field, because main's
  selectAll acts on the focused frame and could grab the transcript.
- Terminals answer through registered xterm handles; the read-only
  agent terminal hides paste.
- The in-app browser guest builds the same menus from the webview
  tag's context-menu event: Chromium's editFlags gate the edit
  verbs, spell-check rides the event, and Inspect element closes
  every menu. Coordinates arrive as window-relative device pixels,
  so the handler divides by the window zoom factor; guest edit
  commands focus the webview first, because they act on the focused
  webContents.
- Bare app chrome falls back to the window verbs.

Labels come from the locale files in all five languages. Main keeps
thin IPC verbs: edit commands, copy-image-at-gesture, spell-check
actions, and dictionary-add for guests (the tag has no session API).
The e2e spec exercises the real focus trap; it is blocked today by
the gateway-checking stall that also fails e2e/chat.spec.ts.
2026-08-18 23:53:07 -04:00
Teknium 5dd15872a6 fix: never evict pinned CIMD sockets from the callback reservation FIFO
The _MAX_RESERVED_SOCKETS cap applied to pinned CIMD sockets too, so under
heavy concurrency an ephemeral-reservation churn could close a parked pinned
socket before _wait_for_callback adopted it, silently reopening the
port-stealing window the pin exists to prevent (#22161). Eviction now skips
the pinned range; it is already bounded by _CIMD_PORTS.

Follow-up to the #84050 salvage.
2026-08-18 20:03:18 -07:00
rob-maron 0b588cb3a4 MCP CIMD auth 2026-08-18 20:03:18 -07:00
Teknium bdd0a79c6a fix(goals): /goal resume actually restarts work after budget exhaustion
After a standing goal auto-paused on turn-budget exhaustion, every
surface's /goal resume handler only flipped the persisted state back to
active (and reset turns_used) and rendered an acknowledgement — nothing
re-entered the conversation loop, so the goal sat idle until the user
sent another ordinary message.

Fix the whole class by scheduling the canonical
GoalManager.next_continuation_prompt() through each surface's existing
input path after a successful resume:

- Desktop/TUI (tui_gateway/methods_tools.py command.dispatch): return a
  sendable {type: "send"} dispatch with the continuation as the message,
  a "Continuing now" notice, and display "/goal resume" so the
  transcript shows the concise invocation instead of the model-facing
  scaffolding. No-goal keeps the exec response.
- Classic CLI (hermes_cli/cli_commands_mixin.py): put the continuation
  on _pending_input, same as the /goal <text> kickoff.
- Messaging gateway (gateway/slash_commands.py): enqueue a continuation
  MessageEvent through the adapter FIFO — the same path the post-turn
  judge uses — so queued real user messages preempt naturally and the
  pause/clear stale-continuation cleanup recognizes it.

Also correct the now-misleading gateway.goal.resumed copy ("Send any
message to continue…") across all 17 locale files.

Regression tests cover exact budget exhaustion → resume on the real CLI
handler, the real gateway handler (including the
_is_goal_continuation_event guard contract), and the TUI
command.dispatch boundary; verified each fails on the pre-fix code.

Fixes #75362
2026-08-18 19:51:52 -07:00
Teknium 51013e9ae0 fix(desktop): /goal clear removes the Goal paused card immediately
`/goal clear` (and pause/resume/status) can come back from the gateway as
a TYPED `{ type: "exec" }` command dispatch instead of the plain
`{ output }` slash.exec shape. The typed exec/plugin branch in
use-prompt-actions/slash.ts rendered the output ("✓ Goal cleared.") and
returned immediately — it never reached the goal-store sync that the
plain-output path runs (`applyGoalStatusText`). The composer status stack
therefore kept showing the stale "Goal paused" card, with the old goal
text, until the chat was left and reopened (which re-hydrates via
`refreshSessionGoal`).

Fix: in the typed exec/plugin dispatch branch, when the command is `goal`,
mirror the dispatch output into the goal store via
`applyGoalStatusText(sessionId, output)` before rendering — exactly what
the plain-output path already does. This covers the whole sibling class
(clear/pause/resume/status/done) since the store's text parser already
understands every /goal output shape; set (`send` dispatch notice) was
already handled.

Tests:
- use-prompt-actions/index.test.tsx: typed exec `✓ Goal cleared.` removes
  the session's goal entry immediately (#80348), and typed exec
  `▶ Goal resumed:` flips a paused card back to active.
- store/goals.test.ts: `✓ Goal cleared.` output clears a paused goal.

Fixes #80348
2026-08-18 19:51:41 -07:00
Teknium 23c1c9815f fix(desktop): Bots sidebar highlight and Cronjobs tile now follow the chat on screen
The Bots roster highlight and the Routines (Cronjobs) tile were keyed off
host.state.profile — the gateway socket's home. Tab/tile focus moves without
swapping the socket, so opening one bot's chat while the socket was homed on
another highlighted the wrong bot and showed the wrong bot's cronjobs
(community report: Newsanalyst chat open, Hermes highlighted).

- sdk: new host.state.focusedSessionProfile — owner profile of the focused
  chat, resolved from the focused stored session's row stamp via
  rememberedSessionProfile() (same ladder as remembered navigation and the
  HUD), with the gateway profile as the draft/uncached fallback.
- hermes-bots: $focusedBotProfile = focusedSessionProfile || profile
  (feature-detected; older desktops keep prior behavior). BotRow highlight,
  RoutinesPane scope, and the $selectedBot tracker use it. Turn-busy 'work'
  mood stays keyed to the socket-home profile (only it can be mid-turn).
- tests: SDK atom behavior (vitest) + plugin source-shape suite; prewarm
  harness stubs gain the new atom.
- docs: SDK page + hermes-agent skill reference list the new atom.
2026-08-18 19:51:13 -07:00
Brooklyn Nicholson 74f99af470 feat(desktop): agent-applied layout presets — apply_layout joins the desktop_ui toolset
The agent could reveal single panes (focus_pane) but had no way to arrange
the workspace as one act. apply_layout closes that gap: a desktop_ui tool
that emits layout.apply over the existing bridge, resolved in the renderer
against the layouts contribution registry — the same list the layout picker
reads — so core presets (default/focus/terminal-deck/quad), plugin presets,
and user-saved presets are all addressable by id. Active session only, same
as pane.reveal: a background turn never rearranges the user's desktop.
2026-08-18 21:06:18 -05:00
hermes-seaeye[bot] b7bed2419e fmt(js): npm run fix on merge (#89619)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-19 01:35:26 +00:00
ethernet 66bb77cbf9 docs(clarify): advertise the questions batch in the tool description
The `questions` parameter had a full description, but the top-level
tool description still described three single-question modes and never
mentioned batching. The model decides how to call a tool from that
description, so it kept asking one question per call.

The description now states that 2-5 independent questions can go in
one call and that one batched call is preferred over a chain of
single-question calls. The parameter description also tells the model
to put a short batch title in the still-required top-level question.
Two schema tests pin the contract: the description names the batch
capability, and the questions parameter stays optional with the
MAX_QUESTIONS cap.
2026-08-18 21:28:53 -04:00
ethernet d9f98fe071 feat(tui): restore the earlier answer when a batch question is re-visited
Tab or Shift-Tab onto an answered question now restores its state, the
same model as the CLI panel. A choice answer puts the cursor back on
its row. An answer that matches no choice was typed via Other, so the
cursor lands on the Other row with the text staged in the input —
Enter then edits the earlier text instead of starting blank. The
restore logic lives in a pure helper (clarifyBatchRevisitState) with
direct tests, because the prompt component has no keystroke harness.
2026-08-18 21:28:53 -04:00
ethernet 9a9015dfa8 test(desktop): batch clarify E2E spec and mock trigger
The mock server gains a batch clarify trigger that scripts a
two-question clarify turn. The scripted turn fires only while the
conversation has no tool result, so the answered batch falls through
to the canned reply instead of a repeat of the quiz.

The spec runs the real chain from composer to renderer and asserts
one batch card, the staged-answers confirm gate, and the settled
card. The local harness cannot boot the packaged app in this
environment (the pre-existing chat spec fails the same way), so the
proof for this spec is the CI run.
2026-08-18 21:28:53 -04:00
ethernet 96b17787c7 feat(tui): Tab cycles batch clarify questions directly
The batch prompt had a separate browse mode: Tab toggled between the
question list and the expanded question, and the arrows walked the
list. Now Tab moves to the next question and Shift-Tab moves to the
previous one, with wrap, and the active question is always the
expanded one — the same model as the CLI panel.

A locked answer now renders on its own indented line in the ok color
under its question, instead of an arrow suffix on the status line, so
the answers stay readable while Tab walks the list. A skipped (empty)
answer renders muted and italic.
2026-08-18 21:28:53 -04:00
ethernet 100380bbf5 feat(cli): batch clarify back-navigation and answer visibility
Shift-Tab walks backwards through the questions, with wrap, the same
way Tab walks forward. A locked answer now renders on its own indented
line in a distinct color under its question, instead of an arrow
suffix on the status line, so the current answers stay readable while
the cursor moves.

A re-visited question restores its earlier state: a choice answer puts
the cursor back on that choice, a typed answer highlights the Other
row and shows the typed text next to it. Enter on an answered Other
switches to freetext with the composer prefilled with the earlier
text, so the user edits instead of retyping. Answer metadata records
how each answer was produced to drive the restore.
2026-08-18 21:28:53 -04:00
ethernet 25c6516607 feat(desktop): single confirm for the batch clarify card
The batch card previously locked each answer with its own Continue
press. Now picks and typed answers stage locally, and one Confirm and
continue button (enabled when every question has an answer) submits
the whole batch. Staged answers stay editable until that confirm.

The wire protocol is unchanged. The confirm sends the per-question
locks in sequence, because the last lock resolves the blocked tool and
each earlier lock must already be accepted when it lands. Replayed
locked answers from a reconnect pre-stage their questions so restored
progress stays visible. The TUI and CLI keep incremental per-question
locks, so a timeout there still returns partial answers.
2026-08-18 21:28:53 -04:00
ethernet 838c4692bc fix(desktop): merge duplicate batch clarify cards
A batch clarify rendered as two identical interactive cards. The
tool.start row carries the model tool_call_id and the clarify.request
row carries a gateway request_id. The hydration-race merge correlates
the two rows with the top-level question text. A batch payload has no
top-level question, so the rows never matched and the card mounted
twice.

The correlation key for a batch now comes from the joined per-question
texts. The NUL separator cannot occur in real question text, so a batch
key cannot collide with a single-question key.

New coverage: two hydration-race tests for the batch shape (tool.start
first and clarify.request first), a mock-server batch clarify trigger,
and an E2E spec that runs the full chain and asserts exactly one card,
the per-question locks, the Confirm and continue relabel, and the
settled card.
2026-08-18 21:28:53 -04:00
ethernet 86a6865e6e docs: describe multi-question clarify batches per surface 2026-08-18 21:28:53 -04:00
ethernet b4526d46d0 feat(cli): compact multi-question clarify panel
The clarify callback accepts a questions list and renders a batch
panel. The batch panel shows all questions as a status list with one
expanded active question. Enter locks the active answer and moves to
the next unanswered question. Tab cycles questions for any-order
answering. Locked answers stay editable until the batch completes. A
timeout returns the locked partial answers with a timed_out flag. The
single-question panel is unchanged.
2026-08-18 21:28:53 -04:00
ethernet a9b8d69a11 feat(tui): compact multi-question clarify prompt
Batch clarify renders as a status list — every question on its own
line (✓ answered / ▸ current / · pending) with only the active
question's choices expanded, so a 5-question batch stays a few rows
tall. Enter locks the active question's answer (clarify.respond with
question_id) and the cursor jumps to the next unanswered question;
Tab walks the question list to answer in any order; the hint reads
'confirm and continue' when one question remains. Esc cancels the
whole batch.

Answered rows collapse to '✓ question → answer'. The abandoned-prompt
transcript record keeps locked partials (they survive a server-side
timeout), and reconnect replay seeds them back into the overlay.
2026-08-18 21:28:53 -04:00
ethernet e0d8a2eb89 feat(desktop): multi-question clarify card with per-question locks
The clarify card renders every batch question at once. Answers stage
locally per question; the footer button locks the staged answer with a
clarify.respond keyed by question_id. Locked answers stay editable — a
new pick un-locks the row and a re-lock overwrites server-side. When
exactly one question is unanswered the button relabels to Confirm and
continue, and that final lock completes the batch. Skip cancels the
whole batch (no question_id). Reconnect replay seeds the locked map so
a reattached window restores its earlier state.

The settled card lists every question with its answer; blank answers
render as Skipped. Single-question cards are untouched.
2026-08-18 21:28:53 -04:00
ethernet 879d6a4c78 feat(tui_gateway): batch clarify bridge with per-question locks
One clarify.request carries the question list (qid, question, choices,
multi_select per entry). clarify.respond gains an optional question_id:
each respond locks one answer, a repeat respond overwrites it, and the
batch resolves when every question is locked. A respond without
question_id keeps its existing meaning (cancel the whole prompt).

Locked answers survive the deadline: a timed-out batch returns the
partial answer map with a timed_out flag instead of an empty string.
The reconnect replay snapshot also carries the locked answers, so a
reattached client restores its per-question state.

Both agent-side clarify dispatch sites forward the questions arg.
2026-08-18 21:28:53 -04:00
ethernet bd8b658a63 feat(clarify): accept a questions batch in the clarify tool core
The clarify tool gets an optional questions parameter (2-5 independent
questions, issue #18450). Batch-capable platform callbacks receive the
normalized list in one call and reply with per-question answers. Legacy
callbacks are looped one question at a time. The loop stops on timeout
so the user is not asked the remaining questions after they walk away.
Locked answers survive a timeout: the result carries them plus a
timed_out flag, and unanswered entries have an empty user_response.

The single-question path is byte-identical to the previous behavior.
2026-08-18 21:28:53 -04:00
ethernet 1f234a1033 fix(nix): let the install-method stamp name a home-manager install
detect_install_method reads the stamp against an allowlist. The
allowlist held "nixos" but not "home-manager", and a stamp that names
home-manager gave "unknown". The managed path (step 3) returned the
correct name, so the gap was invisible: it appeared only for an install
that carries a stamp.

An install with the value "unknown" gets "hermes update" as its update
guidance. That command is the one command a managed install refuses, so
the user gets a dead end.

The test for this was also environment-dependent. It called the real
get_project_root(), and it passed here only because this worktree
carries no stamp. A checkout from the curl installer carries a "git"
stamp, and the assertion then failed for the contributor and not for
us. The test now detects against a temporary install tree.

The new test stamps each managed system and asserts the value that
comes back. With the allowlist reverted, the home-manager case fails
with "assert 'unknown' == 'home-manager'". The nixos case passes,
because that name was already in the allowlist.
2026-08-18 20:42:06 -04:00
ethernet 00c3872882 feat(ci): add nix flake check as unrequired job
The workflow owns its triggers and ci.yml does not call it. A
reusable-workflow call holds the caller run in progress for the full
build, and GitHub refuses `gh run rerun` on a run that is still in
progress. A separate run reruns and cancels on its own.

The job restores /nix/store from the GitHub Actions cache and saves from
main only. A cache that a PR writes is visible to that PR alone, so a
save there spends the quota of the repository and helps no later run.
2026-08-18 20:42:06 -04:00
ethernet 1dbe469276 refactor(ci): hoist docker detect-changes into the .py file
The docker.yml gate held its own copy of the build formula, in shell.
classify_changes.py now owns a derived docker lane, and the nix lane in
the next commit derives from the same file. Two formulas in two
languages drift apart, and one Python function with tests does not.
2026-08-18 20:42:06 -04:00
ethernet d5a9c2ba6c feat(nix): home-manager module, shared with the NixOS module
Hermes is an agent for one person. The credentials, the memory, the
sessions and the cron jobs all belong to that person. But the only
declarative path was a NixOS system service. Issue #9056 asks for the
user-level equivalent. 25 public Nix configurations already write one by
hand, and several of them copy nix/nixosModules.nix and edit the systemd
part.

This module is not a second copy of that file. The code that both modules
share moves into nix/moduleCommon.nix:

  - the options
  - the renderers for config.yaml, .env and the documents
  - the activation body
  - the command lines of the processes

nixosModules.nix keeps only the parts that need root. Those parts are the
service user, stateDir, addToSystemPackages, container mode and tmpfiles.
The file goes from 1008 lines to 666.

`services.hermes-agent` is now the same option set on both modules. A
NixOS example works on Home Manager without a change, and an option added
one time appears on both.

The Home Manager module is different only where it must be. It uses
systemd.user.services on Linux and launchd.agents on Darwin. It uses
home.activation and not system.activationScripts. It sets HERMES_HOME
directly, with the default ~/.hermes, so an existing directory continues
to work. It uses the modes 0600 and 0700, because the state has one user
and does not need the group-shared umask of the NixOS module. It does not
support container mode, which needs root and the Docker socket.

The change also makes four corrections that apply to both modules:

- backend.mode runs `hermes serve` or `hermes dashboard`. Both modules
  had only the gateway. But Hermes Desktop and the web dashboard connect
  to a different process, so six of the configurations in public repos
  add a second unit by hand. serve and dashboard are one entry point with
  one flag of difference, and you can run only one of them. Thus the
  option is an enum. The NixOS module asserts against container mode with
  a backend, and does not make a unit that cannot start.

- hermesHomeFiles installs files into HERMES_HOME. The `documents` option
  installs into the working directory, which is correct for AGENTS.md but
  wrong for SOUL.md and memories/. Hermes reads those files from
  HERMES_HOME, in agent/prompt_builder.py:2095. A SOUL.md in `documents`
  made a workspace file that Hermes never loaded as the identity. The
  documentation said this in prose, but two directory diagrams showed the
  opposite. This change corrects both. A key in either option can now
  contain subdirectories.

- `documents` needs an explicit `workingDirectory`. The default of that
  option is bad on both modules. It is the home directory of the user on
  Home Manager, and ${stateDir}/workspace on NixOS. A user who declares
  workspace files without a directory therefore gets a place that the
  user did not select. The place is also different on each module. The
  modules now refuse that combination.

  The test is on the priority of the option and not on its value. An
  option that nothing sets keeps the priority of its own default, and
  each definition from a user is stronger. Thus a directory with the same
  text as the default still counts as a selection, and so does a
  mkDefault. A comparison of values detects neither case.

- Each activation writes .env again from a base in the Nix store, and
  does not add to the file that exists. Thus a second activation cannot
  put the same secret in the file two times, and a removed
  environmentFile goes away. environmentFiles keeps the type `listOf
  str` and not `path`, so Nix cannot copy a sops-nix or agenix path into
  the Nix store, which all users can read.

- HERMES_MANAGED and the .managed marker now hold the name of the system
  that manages the install. Thus a refusal says "managed by home-manager"
  and not "managed by NixOS", and `hermes update` gives the Nix guidance
  for both shapes. The CLI does not print a rebuild command for each
  system. It names the owner, and the user knows their own tool. A bare
  `true` and an empty marker still mean NixOS, so this does not change an
  existing install.

Verification. Six new checks, all built:

  nixos-module           evaluates the module with evalModules and the
                         NixOS module list. It asserts both units, one
                         HERMES_HOME, and that the module refuses
                         container mode with a backend.
  home-manager-module    evaluates the module with the
                         homeManagerConfiguration function of
                         home-manager. The process assertions run against
                         systemd units on Linux and launchd agents on
                         Darwin.
  module-option-parity   asserts that each shared option is on both
                         modules, and that the two exclusion lists name
                         only options that exist.
  env-file-assembly      runs the real .env script and checks the
                         contents, the mode, that a second run gives the
                         same bytes, and that a removed file goes away.
  workspace-files-need-a-directory
                         checks that the module refuses `documents`
                         without a directory, and accepts a directory
                         that has the same text as the default.
  service-argv           runs each command line that the modules build
                         through the real parser of the CLI, with one
                         sentinel flag added, and requires that argparse
                         refuses only the sentinel.

`nix flake check` passes, with 21 checks in total.

The CLI branches that treat an install as a Nix install move to one
helper, is_nix_install_method. Four call sites in main.py, web_server.py,
update_cmd.py and doctor.py tested the literal set {"nix", "nixos"}, and
each one missed home-manager. recommended_update_command asks the managed
state before the code-scoped stamp again, because a managed install can
carry a stale stamp that names an update path the managed guard refuses.
The metrics contract gets a home-manager bucket, so a Home Manager
install does not report as unknown.

Each check was mutation-probed. 22 faults were injected, and the checks
caught all 22:

  - a lost --no-open
  - a backend that runs the gateway
  - an overwritten config.yaml
  - documents in the wrong directory
  - a different HERMES_HOME on the two processes
  - a lost HERMES_HOME export
  - a missing backend unit
  - a removed assertion
  - an .env file that grows at each activation
  - an install that reports NixOS
  - an empty .managed marker
  - an option on the NixOS module only
  - a stale entry in an exclusion list
  - a renamed subcommand
  - an unknown flag
  - the workspace-files assertion always passes
  - the assertion compares values instead of priorities
  - an off-by-one that lets an untouched default through
  - the assertion also fires for hermesHomeFiles
  - a mkDefault no longer counts as a selection
  - the Home Manager module stops wiring the assertion
  - the NixOS module stops wiring the assertion

The 16 Python tests in tests/hermes_cli/test_managed_install_shapes.py
were probed the same way. 8 faults were injected and 8 were caught.

These tests fail on this tree. They fail in the same way on the stashed
HEAD, and they have no relation to Nix:

  - test_git_probe_tree_kill.py (2 tests)
  - test_update_import_guard.py (1 test)
  - test_telegram_media_read_timeout.py (2 tests)
  - test_teams.py (a collection error)

Closes #9056

# Conflicts:
#	hermes_cli/main.py
#	hermes_cli/update_cmd.py
#	hermes_cli/web_server.py
2026-08-18 20:42:06 -04:00
ethernet 3c675019f1 fix(aux): retry once without response_format when a provider rejects it
Some providers reject the structured-output request field with a hard
400. The error classifier marks a 400 as non-retryable, so one rejected
field failed the whole auxiliary call. Session titles stayed derived
forever (#82816), and no fallback fired.

Three rejection shapes are covered, from live reports:
- vLLM gateways translate response_format into guided_grammar and fail
  when the grammar backend is absent (compile_grammar_error: No module
  named 'xgrammar').
- Some OpenAI-compatible endpoints answer "This response_format type
  is unavailable now".
- Anthropic-compatible gateways that predate structured outputs reject
  the translated field: "output_config: Extra inputs are not
  permitted". The documented case is the bedrock-mantle Messages
  endpoint.

The fix is reactive, the same pattern as the temperature and
max_tokens rungs: when the provider rejects the field, retry once
without it. Callers tolerate an unconstrained reply — the title prompt
demands bare JSON and _extract_title_text has a loose-JSON fallback —
so the call succeeds with prompt compliance instead of failing. The
retry only fires when the request carried the field, and both the sync
and async paths get the same rung.

Closes #82816
2026-08-18 20:34:46 -04:00
ethernet 8f2d61e3de fix(aux): translate top-level response_format kwarg on the Anthropic adapter
The adapter builds the Messages body from a fixed allow-list of kwargs.
A caller that passes response_format as a top-level kwarg (the OpenAI
SDK call shape) got it dropped on the floor. The request succeeded, but
the schema contract silently became prompt compliance. No in-tree
caller uses this shape today. The pin-test makes sure that a future
refactor cannot open this leak again.

The top-level kwarg gets the same output_config.format translation as
the extra_body shape. When a caller sends both shapes, the extra_body
value wins because every in-tree caller uses that shape.

Pin-test pattern from PR #85626 review follow-up.

Co-authored-by: Matt McClean <mmcclean@amazon.com>
2026-08-18 20:34:46 -04:00