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.
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.
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
`/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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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>
Plugin structured completions (plugin_llm.complete_structured) build an
OpenAI Chat Completions response_format payload in extra_body. The
anthropic_messages transport forwarded it verbatim, and strict
Anthropic-compatible gateways reject it with HTTP 400:
response_format: OpenAI Chat Completions structured-output shape is
not supported. Use output_config.format = {"type": "json_schema", ...}
Observed live: every discord-thread-autotitle structured call failed
for 2+ days (1,600+ logged errors) once the main provider became an
anthropic_messages gateway.
Fix: _translate_anthropic_response_format converts
- json_schema -> output_config.format = {type: json_schema, schema: S}
- json_object -> permissive object schema (SDK 0.87.0 has no
schema-less JSON mode)
merging into any existing output_config (adaptive-thinking effort
coexists) and excluding response_format from the raw extra_body
passthrough alongside the existing reasoning exclusion. The async
adapter delegates to the sync adapter via asyncio.to_thread and is
covered by a test. Non-Anthropic transports are unchanged.
Customer gateways cross WAN paths to the connector; the websockets
library defaults (ping 20s, pong deadline 20s) close the socket with
1011 keepalive ping timeout under transient latency or event-loop
stalls — the trigger of the Coatue 2026-08-18 incident. 60s tolerates
stalls while still detecting a genuinely dead link within ~90s worst
case. Set explicitly at both connect() call sites (with and without
auth headers) so the tuning is visible and pinned by test rather than
inherited from library defaults.
Between an unexpected close and the supervisor's successful re-dial,
self._ws still points at the DEAD socket, so the existing None-check
does not cover the backoff window: a send registered a future no reader
could resolve and blocked the caller for _outbound_timeout_s.
A live supervisor task is exactly the redial window (the loop returns
when a dial succeeds and its fresh reader takes over), so no new state
is needed: when self._supervisor is not done, return the error dict
immediately ({success: False, error: reconnecting}). Callers already
handle failed sends; a fast, honest failure beats a 30s wedge
(Coatue 2026-08-18).
The reader is the only thing that can resolve a pending outbound_result
future. When the socket dropped unexpectedly (vs a deliberate
disconnect(), which already failed pending), every in-flight
_request_response waiter blocked the full _outbound_timeout_s (~30s) on
a future no reader could resolve.
Coatue incident 2026-08-18: a 1011 keepalive ping timeout close left
final-send and error-notification calls wedged against the dead socket,
holding sessions active while the connector replayed inbound backlog.
Fail every not-done pending future from _read_loop's finally with the
dict shape callers expect ({success: False, error: connection lost}) —
never an exception on the outbound path — then clear the map. list()
snapshot avoids mutation-during-iteration from woken waiters' finally
pops.
Builds on #89551 (@calvinnwq, cherry-picked): his showCloseButton flag
hid the hover X; this completes the model so standing chrome can never
be closed at all, only shown/hidden (#89546).
- hideOnly pane chrome (sessions + Bots): no hover X, no middle/meta
click close, no Close verbs in the tab menu, excluded from
close-others/right/all sweeps
- zone right-click menu gains Show/Hide rows for the strip's chrome
tabs (Hide bots / Show sessions, localized in 6 locales)
- Cmd-K palette: auto-registered "Toggle <tab> tab" rows for every
hideOnly pane, on-screen truth semantics, plugin panes included via
registry subscription
- hides persist across launches (survive the enforced dock re-adopt);
reveal intent and Layout reset clear them
- last-visible-tab guard: hiding the zone's last shown tab is refused
with a toast, so the strip can never become an empty dead zone
Add a pane-level opt-out for the hover close button and apply it to the persistent Sessions and Bots navigation panes. Keep their existing close handlers and other tab behavior intact.\n\nFixes #89546
The salvaged fix persisted the recovery note unconditionally — correct for
the synthesized empty auto-resume turn, but a user who typed real text while
resume was pending would get the [System note: ...] scaffold persisted as
their own words, leaking scaffolding into the durable transcript (the same
class as #81841 on the assistant side).
_prepare_resume_pending_message now persists the note only when the original
message is blank; real text persists verbatim while the model still receives
the wrapped note. Whitespace-only counts as blank. Tests cover all three
shapes.
The deeplink-driven plugin install flow shipped in #89464 (salvage of
#82735 by @serefyarar) had no docs. Adds:
- user-guide/features/plugins.md: "One-click install links (Desktop)"
section under Managing plugins — link forms (repo/enable/force), the
confirm-first dialog contract (never auto-installs, same install-time
security scanning as the CLI), hybrid-repo behavior, legacy
plugin-agent/plugin-desktop routing, hermes-dev:// in dev builds, and
the no-SDK anchor example. Cross-links the MCP "Add to Hermes link"
equivalent.
- developer-guide/desktop-plugin-sdk.md: "Distributing with an install
link" section so plugin authors find the link form next to the
packaging docs.
The salvaged writer-side fix stamps api_content on NEW hidden redirect
placeholders, but rows persisted before it (content="" + display_kind=hidden,
no sidecar) would keep re-triggering repair_empty_non_final_messages on every
call forever. Substitute [response interrupted] on the wire copy at the
api_content/display_kind projection stage so legacy sessions converge too.
Never the interrupt scaffold (#81841). Durable transcript untouched.
Regression tests drive run_conversation end-to-end with a spied sanitizer:
the projection must leave the sanitizer nothing to heal (its per-turn warning
spam is the bug), verified failing via sabotage run against the writer-only
fix.
Projection-side approach credit: @JoaoMarcos44 (PR #88996).
The tracked contributors/emails/agent@Agents-Mac-mini.local and
agent@agents-Mac-mini.local differ only by case, which cannot materialize on
case-insensitive filesystems and surfaces one of them as perpetually modified
in git status, breaking clean checkouts. These entries are stale agent
identifiers, not real contributors; remove both.
Bot-mode interrupted member turns with no visible assistant text persisted an
empty assistant row (content="" + display_kind="hidden"). The pre-call
sanitizer repair_empty_non_final_messages() re-healed that row on every later
call (wire copy only), so the loop never converged (#88955).
Stamp api_content="[response interrupted]" (the canonical
_INTERRUPTED_PLACEHOLDER) on the hidden placeholder instead. display_kind is
stripped before sanitization, but api_content is projected back into content
for historical assistant rows, so the provider sees a non-empty neutral turn
and the sanitizer stops touching the row — while the durable transcript stays
hidden and empty. Uses the neutral interruption text, never the
_INTERRUPTED_SCAFFOLD_MARKER, which replaying as assistant text caused #81841.
Adds regression coverage proving (A) the placeholder carries the replay
sidecar, (B) two consecutive projections converge without sanitizer healing,
(C) the sanitizer still repairs genuinely-empty unmarked assistants.
Refs #88955
Completes group/1:1 attachment parity (#88983). PR #89486 covered images;
this adds the remaining half:
- The composer picker accepts any file type; kind (image/pdf/file) decides
the staging RPC. Paste handlers accept non-image files too.
- Drag & drop anywhere on the room drops into the active composer (open
reply box, else main), with a drop overlay naming the target.
- Member turns stage PDFs via pdf.attach (rendered per-page into vision
tiles by the gateway) and other files via file.attach; each returned
@file: ref is appended to that member's turn prompt so file tools can
read the artifact. Failed attaches still degrade to text-only.
- Transcript markers distinguish [attached PDF: x] / [attached file: x] /
[attached image: x]; room log renders non-image attachments as named
chips, pending chips show type icons.
Tests: 3 new vm-harness tests (per-kind RPC routing across members,
@file: ref injection into turn prompts, transcript labels); 279 total pass.
Review follow-up on the salvaged #88965 work:
- test_goal_command_slow_db_init_still_persists: drop the 4s slow-init
loop-gap harness (wall-clock gap assertions on shared CI runners are
their own flake class; loop-freeze bounds are already covered in
test_goals_db_bootstrap_off_loop.py). The persistence contract keeps
its discriminating power by shrinking the monkeypatched init window
(0.2s) under a 0.8s slow init — the window-only path still fails it.
- test_slow_construction_does_not_block_the_loop: monkeypatch both
bootstrap windows down (0.3s/0.05s) and shrink the blocking init from
6s to 1.5s; the two-window contract is what's under test, not the
production constants. Adds an elapsed ordering assertion so the
kick-vs-in-flight window distinction stays pinned.
Combined wall time for the pair: ~10.5s -> ~1.4s.
Review fixups for #88965. The goal, loop, and heartbeat managers each
had a copy of the same WARNING text. The shared _warn_dropped_write
helper in goals.py keeps the three logs identical and greppable as one
bug class. The _warm_goals_session_db parameter is now label. The old
name ctx said context, but the value is a log label.
The loops delegation (previous commit) moved /loop onto the shared
bootstrap windows, but the loop-class gateway callers still constructed
LoopManager on the loop thread with no warm-up — the same false-ack
class as /goal, one sibling over:
- The /loop command handler: a cold init past the window made
save_loop discard the write while the reply claimed the loop was
set. Reproduced with a 2s init: "↻ Loop set" with nothing persisted;
with the warm-up the loop persists.
- _post_turn_loop_completion: a cold cache at the turn boundary
stalled the loop for the init duration and could drop the
tick-completion write.
- _loop_wakeup_watcher: the scan reads every persisted loop, so a cold
cache ran the state.db init on the loop thread before the first
read.
All three now warm the cache off-loop first (same helper, same reason
as the goal paths). save_loop also logs at WARNING when it drops a
write, matching save_goal: the reply has already told the user the
loop was set.
Review findings (PR #88965): the goal and heartbeat command paths
warmed off-loop, the loop-class paths did not.
A fresh state.db init (schema DDL, FTS tables, first config import)
measures ~300ms warm on a fast machine. The gateway constructs
GoalManager on the event-loop thread, and a cold cache ran that init
behind a 0.25s bootstrap grace window: on a slow CI box the /goal set
path's waits expired and save_goal silently no-oped — the reply said
"Goal set (7-turn budget)..." but nothing persisted, and a fresh
GoalManager read back no state (first assertion passes, second fails).
Two changes, one per caller shape:
- Async callers (_get_goal_manager_for_event,
_get_heartbeat_manager_for_event, _post_turn_goal_continuation, and
the heartbeat poller) warm the SessionDB cache off-loop through the
context-preserving executor before constructing the manager (shared
_warm_goals_session_db helper). The loop never blocks and the first
write lands at any init duration. A bare to_thread would lose the
per-turn profile home override under multiplex; the executor hop
keeps it (same pattern as the goal judge path).
- Sync callers (heartbeat persistence, _goal_still_active_for_session)
cannot await, so the bootstrap windows stay: the call that starts the
bootstrap waits a one-time init window (1.5s) instead of the short
per-call window (0.25s), giving healthy cold inits room to land while
a contended migration still degrades to None with only a bounded
one-time stall. The bootstrap thread binds the caller's home as a
contextvar override so a multiplexed worker cannot cache the default
profile's DB under another profile's key.
save_goal and heartbeat save_state now log at WARNING when they drop a
write, because the reply has already told the user the state was set.
Regression test pins the contract: init past the window, write
persists, loop gap under 2s (the flake-policy floor for wall-clock
bounds; the slow-init margin grew to match, so the test still tells
on-loop from off-loop).
Independent diagnosis + measurement by jackulau (#88965 review); the
off-loop warm-up shape follows their harness table. Simplify-code
review (4-agent) contributed the helper extraction and the poller
warm-up.
New agents now get a blobatar — a deterministic soft-body face generated
from the bot's name (same name, same face, forever) — as the default
shapes mode, with full manual control:
- Face follows the name live while typing in New Agent
- Randomize re-rolls the seed; Lock face pins the current one so a later
rename can't change it (Unlock returns to name-following)
- Any of the six silhouettes (round/organic/boxy/nub/cloud/sun) can be
pinned via frozen-per-major trait positions while the rest stays
name-derived
- Classic geometric shapes remain one click away, and existing bots keep
their stored looks untouched
Wiring: blobatar@0.2.0 (zero deps, ~3.7KB) exported through the plugin
SDK (blobatarSvg / Blobatar), feature-detected in plugin.js with a
legacy-shape fallback for older desktops. Blob shape strings are
'blobatar[:seed[:kind]]' inside the existing meta.shape field, so
persistence, cross-machine ui_meta sync, and the roster's PNG backfill
(data-bot-face tag preserved) all work unchanged.
Second layer of the "make hydration feel instant" work (on top of the
paint-first wait): persist a bounded tail (40 msgs / 256KB / 50-session
LRU) of every reconciled transcript in localStorage, keyed by durable
stored-session id.
- Cold resume paints the cached tail immediately — the wake is visually
complete before any network I/O; the REST prefetch / runtime resume
reconcile the authoritative transcript over it when they land.
- The cached paint is DISPLAY-ONLY: reconciliation treats the view as
empty (viewMessagesForReconcile), so authoritative content replaces the
provisional paint wholesale — no grafting onto stale rows. Failure
latches also treat it as empty, so a cached paint can never mask a
genuinely stranded resume; a resume that proves the session empty rolls
the paint back and drops the poisoned entry.
- Saves happen only post-reconcile (cold path + warm activate path);
deletes drop the entry; a gateway/mode re-home wipes the cache (another
backend can recycle stored ids).
Sabotage-proven: disabling the cache load fails 5/7 cache tests; the
display-only contract is covered by the existing resume reconciliation
suite (663 tests green).
wake instead of the full runtime boot (#89206 class)
zero trust's third bundle (on ae6578af, both prior fixes present) showed the
remaining failure: cold profile backends on slower Windows machines take
47-120s to fully boot, while the wake path's fixed budgets (20s hydration,
~15s resume retries) raced the whole boot and lost — "errors waking up BOTS"
while the backend came up healthy moments later.
Rather than raising timeouts, make the wake cheap:
- waitForFocusedSessionHydration: a history-bearing chat is hydrated when
the persisted transcript is PAINTED on the right session. The REST
prefetch delivers that seconds after the backend's HTTP is up; the full
runtime resume (agent build, MCP discovery, 114-skill load) keeps warming
in the background and binds the composer when it lands. Only an
expected-empty chat still waits for the runtime (nothing to paint).
- On hydration timeout, log a [bot-wake] phase breakdown (activation ms,
hydration ms, which conditions were unmet) to the renderer console so the
next support bundle pinpoints the slow phase directly.
- web_server: flush the headless "listening" line — block-buffered on the
Desktop's piped stdout, it surfaced minutes late and made boots look far
slower than they were in support bundles (the 120s "gap" in this bundle
was partly this artifact).
Sabotage-proven: restoring the runtime-gated wait fails the new paint-first
test by timing out — the exact field shape.
Group rooms were text-only on the ingest side: the composer and
runGroupChatMemberTurn submitted prompt.submit {text} with no attach step,
so a user screenshot could never reach the members' models (community
report from Osiris). The gateway already ships the staging pipeline
(image.attach_bytes -> attached_images -> next prompt.submit) and the 1:1
canonical chat uses it — this wires the group surface to the same path.
- Composer + thread reply boxes: attach button, Ctrl/Cmd-V paste, pending
chips with preview/remove, image-only sends allowed.
- Attachments are downscaled (long edge 1568px) and stored on the room-log
entry, so reloads keep showing what members were shown.
- Turn drive stages the delta's images into EVERY responding member's own
per-group session via image.attach_bytes before its prompt.submit —
works cross-connection through requestForBot; a failed attach degrades
that member to text-only rather than failing the turn. Watermarks
guarantee an image is staged at most once per member.
- formatGroupChatLine names attachments ([attached image: name]) so the
transcript delta and the staged pixels line up for every viewer.
- Room log renders attached images on user entries.
Tests: 6 new vm-harness tests (fan-out staging order, mention-scoped
attachment routing, image-only sends, no re-attach across turns,
transcript naming, invalid-attachment degradation); 276 total pass.
Third CI hit today for tests/gateway/test_goal_verdict_send.py (twice on
salvage PRs, once on main's own push run), always the same shape:
adapter.sends == [] after the full drain.
Mechanism (reproduced, not log-read): the tests call GoalManager.set() on
the event-loop thread. _get_session_db() refuses to construct SessionDB on
a loop thread (loop-liveness guard from the 2026-08-14 crash-loop fix) and
waits only _DB_BOOTSTRAP_LOOP_WAIT_S=0.25s for the background bootstrap.
On a loaded CI runner the init overruns that window, set() degrades to a
silent no-op by design, the goal never exists, and the continuation path
correctly does nothing — so no amount of drain-waiting helps (the #88975
de-flake addressed a different, downstream race).
Fix: the hermes_home fixture pre-warms the SessionDB cache from its sync
context (direct construction path), so the loop-thread set() always finds
a cached DB. Production behavior untouched.
Proof: injecting a 0.4s-slow SessionDB.__init__ reproduces the exact CI
failure on the old fixture and passes 2/2 with the pre-warm.