A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:
- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
preflight compaction were settled against the main model and undoes it before memory sync and
background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
delegations keep using the session model.
A free-tier user is offered sign-in 3 minutes after their first finished
task, whether or not they send another prompt. Later offers each need
another finished task after the previous one, come 3 minutes after that
task, and never sooner than 1h, 4h, then 24h after the previous offer, so
a user who keeps saying "Not now" hears it less often.
- hermes_cli/free_tier_offer.py: the policy. One install-wide record
(<default root>/free_tier/sign_in_offer.json, file lock + atomic write);
claim_offer() is compare-and-set so two windows never both show it.
Nothing is due off the free tier.
- tui_gateway/free_tier_task_done.py: a "finished task" is a completed
turn with a reply, on the free-tier agent, not a crash resume, and with
no steer/redirect/interrupt/busy-typing while it ran. Called from
prompt_turn after a complete turn, before message.complete goes out, so
a status read prompted by that event already sees the record. Intervention is reported by the agent
(result["user_intervened"], reset per turn) and marked on the session by
the steer/redirect/interrupt RPCs and busy submits.
- Wire: free_tier.status gains nudge_due_in (seconds, 0 = now, absent when
none is pending); new free_tier.claim_nudge -> {claimed}.
The old renderer handoff was the RPC's only caller; the setup flow hands
off through start_chat and writes no memory. Removes the writer module,
the handler and its contract models; the TypeScript contract is
regenerated.
Brings the onboarding branch up to main 0c2ff854a5. Where a Wave A PR
already landed a reviewed version of a piece of this branch (free tier
#134049, keyless web #134048, app resolver #134050, bundle and uninstall
#134051, catalog card and toolsets #134052, built-in tour #134054, stdin
guard #134055, retired key #126146), main's version wins. The PM long-path
files also take main's version: that work lands through #130430.
Kept from the branch on top of main:
- the setup and start_chat toolsets, declared with main's platforms key
(desktop only), and the setup_choose server request contract;
- the setup-profile marker file (.setup-profile.json) instead of the
profile.yaml role, as c3f85bc54f decided;
- the first-contact note's message argument beside main's internal flag;
- the setup-profile gate on the shared-metrics consent dialog;
- window sizing (window-growth) in place of chat-onboarding-window.
Desktop i18n follows main's per-section sibling files. The onboarding
strings go into them (modelMenu.localSetup, handoffTour.localTitle and
localText) and setupChoose/startChat stay in en.ts. guidedGreeting is
gone. The shared-metrics copy uses the branch's rewrite in English, with
the pre-opt-in sentence corrected for the fresh-install note
(d3674513c8); locales keep main's translations, with installId and
consentWindow folded into the new sending key.
The built-in tour reads local-model eligibility from the local-setup
offer store, the one place that rule lives on this branch. The gateway
contract and the onboarding skill docs pages are regenerated.
MEDIA (2f1fabd4bf, the backend MEDIA: parser and attachment parts) is
deferred to its own PR. Its 53 MEDIA-only files go back to the merge-base,
so they take main's version; the 10 files it shares with onboarding keep only
their onboarding hunks (setup_choose and start_chat cards, the start_chat
contracts, the scripted prelude, the first-contact note, the local-setup
turn report, intro_resends, visible_text, the bigger chat headings). The
generated gateway contract is regenerated after the merge with main.
The Store variant's HERMES_GUEST_ONBOARDING bundle env (39af862aec) is
reverted here; it ships through Ari's bundle work.
The session.list projection dropped the durable lineage root returned by
SessionDB, so RPC consumers could not group a compressed conversation
with its continuation — REST (/api/sessions) already projected it, and
the desktop store keys pinning and lineage dedup on the field
(apps/desktop/src/store/session.ts::sessionPinId).
Forward `_lineage_root_id` through `_session_row_summary`, declare it on
the `SessionListRow` wire contract, and regenerate the TS/OpenRPC
artifacts so every consumer sees it.
Fixes#66663
Salvaged from #66675 by @Sanjays2402 (authorship preserved); adapted to
methods_session.py and extended with the wire contract + regenerated
artifacts.
Voice input was record-then-upload on every surface: nothing showed while the user
talked, and the transcript arrived only after the whole take uploaded. With
`stt.streaming: true` the openai, xai and elevenlabs providers now stream microphone
PCM to their realtime endpoints while the user speaks. Partial text renders live and
the final transcript is ready at end of speech.
- tools/transcription_streaming.py: one session API (push_audio / end_audio /
finalize / cancel, an on_partial callback, a worker thread that owns the socket)
with three wires, all verified live: the OpenAI Realtime `type: transcription`
session (gpt-live-transcribe, 16->24 kHz resample since the endpoint refuses
< 24 kHz), xAI wss /v1/stt with interim results, and ElevenLabs Scribe v2
realtime. Plugin providers opt in through
TranscriptionProvider.streaming_capable / open_stream_session.
- tools/voice_mode.py: AudioRecorder taps its capture callback into the session and
parks it under the WAV path. transcribe_recording uses the live transcript and
falls back to the file on any failure or empty result, so CLI push-to-talk, the
continuous VAD loop and the TUI gateway gain streaming with no call-site changes.
- Classic CLI and Ink TUI show partial text in the input placeholder while recording
(new `voice.partial` gateway event, contract regenerated). Desktop dictation
streams PCM to the new /api/audio/transcribe-stream socket (provider keys stay on
the host, and the host resamples) and shows partial text in the dictation pill.
The recorded blob stays the fallback.
The plugin streaming contract (streaming_capable / open_stream_session /
TranscriptionStreamSession) and the transcribe-stream socket shape come from #103173.
Co-authored-by: Vinicius <timelord1025@gmail.com>
Re-check the active chat after the local-model wait and answer 'Tours only run in the session the user is looking at.' when it changed.
Move TourPreset to tools/tour_presets.py so importing the wire contracts no longer registers gui_tour.
A general look around the desktop app is now one tool call. Before, the
model ran a targets scan and wrote its own steps from raw DOM labels: two
round trips, English-only copy.
- gui_tour action='start' without steps runs the built-in tour on
surface='app' (refused on 'preview'). preset 'quick' shows four stops:
chats list, composer, new session, model picker. 'full' (the default)
adds what the interface mode puts on screen. A stop that is not on
screen is skipped. The model stop adds a line when this computer can
run a local model (the local-setup tip's fit check, capped at 2 s).
- TourPreset is defined once, in tools/tour_tool.py. The wire contract
types tour.preset with it, so the renderer uses the generated
TourPreset type. Contracts regenerated.
- The stop copy lives in i18n (appTour, en_app_tour.ts); other locales
fall back to English until the translations PR.
- engine.ts: one shared teardown for every driver exit, which now tells
the host. The app host clears a reactive $tourActive flag, so the
usage-stats consent dialog waits until no tour is on screen. A closed
one-off highlight also clears it.
- en.ts and types.ts are over the size cap, so the existing handoffTour
copy and type move unchanged into the same siblings to offset appTour.
test_start_validates_its_steps: a bare start is no longer an error; it is
the built-in tour, and start on surface='preview' without steps is.
An agent stores its session platform's gated toolsets in disabled_toolsets at load, so the tool_search listing, the bridge's search, MCP refreshes and delegate children drop manage_catalog too.
InstallPhase moves to the leaf hermes_cli/plugin_install_phase.py; the gateway contracts no longer import the installer.
The per-home enable lock moves from the plugins_cmd facade to plugins_cmd_install._enable_placed.
Reusing an installed catalog tree reads the install record through catalog_install_record, so older top-level catalog_name records match.
A failed row no longer redacts the Advanced choices (profile name, commit); only credentials are redacted.
The profiles.py role comment no longer says a role grants the setup toolset.
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
NAS may answer POST /api/anonymous/token with 428 challenge_required and a
portal URL. Hermes implements the one `browser-v1` primitive: open the URL
(hidden where it can), poll /api/anonymous/challenge/status, exchange again.
Everything the page does (BotID, in-browser proof of work, an interactive
Turnstile fallback, its copy) belongs to the portal and changes without a
Hermes release. The mint stays authoritative throughout.
Python (hermes_cli/anon_challenge.py)
- the exchange reports surface (desktop / cli / gateway), version and the
browser-v1 capability; a UA of the documented shape goes on every call
- the wait runs outside the auth-store and shared-store locks, under a
per-profile work lock; only a caller someone is waiting on waits — the
keepalive tick and a status paint announce the challenge and return
- 403 signin_required / access_denied and any 428 this version has no
primitive for become a terminal sign-in error, scoped to the token
exchange; sign-up and the promotion endpoints keep their previous verdicts
- status-poll 429 / 5xx / transport are blips; a 4xx is a verdict (the
credential is gone); a second 428 starts a fail-fast cooldown so a boot
burst of token readers does not each re-mint and reopen a window
- the pending challenge is published as the free_tier.challenge event and
on free_tier.status for a client that connects later; the desktop reports
how each window ended (free_tier.challenge_result) so the waiter re-mints
promptly, and an attempt counter lets a fresh foreground attempt reopen a
window the user closed without a status replay reopening it
- terminal: print the link, open a graphical browser once per ticket; a
gateway process never opens one; control characters never reach the tty
- challenge URLs are only ever opened on the portal origin the exchange hit
Desktop (apps/desktop/electron/challenge-window.ts)
- the page loads in a hidden, sandboxed window of its own partition, pinned
to the portal origin (will-navigate, will-redirect, off-origin commits end
the run); a phase is read only from a main-frame portal URL fragment
- #interactive reveals the window (never for an optional challenge), #done
and #failed close it; an HTTP error commit, a load failure and a crashed
renderer are retryable, a closed window is not; timers are bounded
- the renderer relays the event and the status read to one window per
attempt and reports the outcome once; a backend without the RPC is fine
The backend-apply store consulted the receipt's owed steps only on exit 0. A
partial run (record_user_action turns a committed success into partial / exit
1, e.g. a stash whose restore conflicted) fell through to "Backend update
failed." and a receipt-proven partial after the restart to "did not come back
online", dropping the exact recovery instruction; and the success branch read
followups from whatever receipt the status route attached, which is the
latest receipt when this action has none.
Add @hermes/shared updateDebt(receipt, actionId): the debt of a committed
(success|partial) run, only when the receipt's action_id is the id the start
request returned. The store composes it on success and failure: follow-ups
keep the rerun remedy, a user action shows the producer's instruction verbatim
(a rerun does not restore a parked stash). The existing owed-success spec's
receipt gains the matching action_id the real route emits.
Before the type-ahead fix, an Enter pressed while `hermes chat` started
could save "No thanks" before the offer was on screen, and every surface
then treated the profile as answered. Every answer now also writes
`telemetry.shared_metrics.offer_version`. An "off" answer without it reads
as `reask`: undecided, so the CLI, Desktop strip and dashboard banner offer
it once more with a one-line reason. Any response settles it, including
Esc in the terminal and the banner close, which keep "No thanks", so
nobody is asked a third time. Opt-in answers were always deliberate picks
and are never re-asked.
The gateway.ping heartbeat failed the transport when the newest inbound
frame was older than the 45 s deadline. A hidden or minimized Chromium
window runs the 15 s heartbeat interval once a minute (intensive wake-up
throttling), so at every tick the previous ping's pong is ~60 s old even
though the backend answered it within milliseconds. Every idle hidden
window closed a healthy socket, redialed, and the renderer recorded
hermes.desktop.friction kind=backend_disconnect detail=network for it.
Judge silence only when the newest ping we sent is still unanswered
(lastPingAt > lastLivenessAt). A dead transport still fails one tick
after its first unanswered ping, throttled or not; an answered one is
never dropped for timer spacing. This also covers the suspended-process
shape (OS sleep, WebView freezer, SIGSTOP): the pre-suspend ping was
answered, so the first tick after resume pings instead of failing.
The provider heading's status slot now shows a fuel gauge for the
tightest usage window: hidden while there's room, "17% left · resets
3:00 PM" at 20% or less (amber from 10%), with a bar along its bottom
edge for what's left, then the existing "Limited until" once the login
is out. Hovering lists every window (5-hour, weekly, per-family).
model.options attaches `usage: { windows }` from a per-profile cache in
agent/account_usage_cache.py and refreshes it in the background, so the
picker never waits on a usage API. Every fetch_account_usage call lands
in that cache, including the one session.usage runs after each turn, so
the chip is at most a turn or two minutes behind. Covers the built-in
fetchers (Anthropic, Codex, OpenRouter) and any provider plugin's usage
hook.
Both pickers now render one ProviderStatusChip instead of separate
limit badges.
model.options now attaches `limit` to a row whose credential pool is
rate-limited: `account` (every live entry benched credential-wide, e.g. a
Claude Pro/Max 5-hour window) with the pool's own reset time, or `models`
(only those models are cooling down, each until its own time). Read from a
new read-only CredentialPool.limit_state; entitlement benches aren't reset
windows and stay out.
Desktop renders it in both pickers: an amber "Limited until 4:30 PM" badge
on the provider heading with its models dimmed but still selectable, or a
"resets 4:30 PM" badge on just the cooled-down row so its siblings read as
the way to keep working. A limit whose reset has passed drops on its own.
When the model answers beside a housekeeping tool call (memory, todo_list,
skill_manage, session_search) and the follow-up is empty, the turn ends on
that pre-tool answer (fallback_prior_turn_content, since #10472). The final
then repeats text the client already streamed before the tool, but nothing on
the wire says so: message.complete looks like a new response.
Record the reuse where it happens (the housekeeping fallback and the
budget-exhaustion reuse of a previewed verification candidate), report
response_reused only when the final still equals that text (a footer or
explanation appended later drops it), and carry it on message.complete.
Never inferred from equal text: "Done." / tool / "Done." are two responses.
Ultrafast collapsed into ordinary Fast across the stack: the catalog only
reported `fast`, presets and drafts stored a boolean, session.create took
only `fast`, and Settings had a single Fast switch, so an Ultrafast default
read as Fast and a new chat could lose it.
- Inventory reports `ultrafast` per model from the same route-aware
resolver that builds the request; Daybreak aliases offer no accelerated
speed (their priority request is ignored).
- session.create accepts an exact `service_tier` that wins over the legacy
`fast` flag; unknown tiers are rejected.
- Desktop keeps the exact tier through presets, draft creation, session
options and labels; an unsupported remembered tier falls back to
Standard, and failed edits reconcile to the confirmed state.
- Settings → Model → Main model offers one Speed choice (Standard / Fast /
Ultrafast) on models that support Ultrafast; others keep the Fast switch.
Split out of #129251 (which keeps Daybreak).
(cherry picked from commit 4e332fb850c989ef9f34bd70aaca174042beecc6)
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
The scheduler persists last_delivery_unverified as a list of unconfirmed
platform:chat_id[:thread_id] targets and cron.manage passes it through, but
CronJobRow typed it bool | None, so contract validation rejected any job
with an unconfirmed delivery. Regenerate the TS/OpenRPC contract.
Change-only desktop.log / agent.log lines that name which writer moved a
bot identity when two connections share a profile name:
- [gateway-route] per (connection, profile): via=primary|secondary and the
window primary; `active` on every active-scope change (key, socket
connection, profile) and `published` on every published-connection
change, so a socket/label disagreement shows up side by side.
- [bot-roster] each Bots-pane snapshot: the install that ANSWERED
profiles.list vs the install expected for activeConnectionId;
MISROUTED when they differ.
- [bot-relay] rows per connection with the answering install; MISROUTED
when two connections answer from the same install.
- [bot-meta] every $botMeta title change per scoped key.
- backend: bot_relay.roster.sync and ui_meta['hermes-bots'].title writes.
profiles.list now echoes install_id (same value as /api/status) so a
client can prove which machine answered a routed list. Additive field;
older clients ignore it. Plugins reach the tracer through
host.traceIdentityChange?.() so wholesale SDK mocks stay valid.
The plugin deep link (hermes://plugin/install?catalog=) and the skill
deep link are separate features: one per kind. Reverting #119381's card
catalog should not leave skills without a link, so this restores the
skill route on its own:
- Desktop: skill-deeplink-install store, the skill route in
deeplink-routes, its use-desktop-integrations wiring, and the
confirm-store onConfirm/details/busy/done phases its dialog uses
(restored from main with their tests). Its nine strings move from the
deleted catalog.* namespace to skillDeepLink.* (en + the six locales
that translated them).
- Website: Skills Hub "Install in Hermes" button, plus the
source-qualified installIdentifier from extract-skills.py so the link,
the embedded picker and the CLI command all name the exact skill
(apps/shared/src/catalog-install.ts keeps only the skill helpers).
- Docs: skills.md "Install from the website" section, desktop.md pointer.
The card catalog UI stays reverted.
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.
Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
reels/masonry, install switch) and skill deep-link install
(hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
locales/_keys.desktop.json); the two i18n pack tests and the keys
test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
the catalog half of c13232e90c / f265273347.
Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
(the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
a provenance pill only for agent/hub skills, since the pre-#119381
labels have no `external` entry.
The Desktop and TUI subcommand pickers offer /browser use (it is in the
registry's subcommands), but both handlers only knew connect, disconnect
and status, so picking it printed the usage line.
browser.manage gains a use action (enabled: bool) that writes the session
profile's browser.backend through the same set_browser_use_mode helper the
CLI now calls, and status/use report browser_use. The handler is profile
scoped, so a worker session writes its own profile. A live agent keeps its
tools (prompt cache), so Desktop and TUI say the switch applies to new
chats instead of swapping tools mid-conversation. use and status also run
on remote backends; connect/disconnect stay local-only.
session.events.since reports truncated=true when the ring evicted events
between the client's watermark and its oldest retained seq. The client
ignored the flag and dispatched the retained tail as a gap-free replay,
painting responses missing their prefix, tool state or terminal event.
On truncation, skip the window up to the server's head without
dispatching it. The session's replay barrier still resolves true, so the
reconnect backstop's history read (#94779) resyncs from the stored
transcript; parked live frames newer than the head still dispatch.
Covers the client half of #100122; the server-side session-eviction
seq reset is #124587.
The retry notification added in this PR re-invokes forkBranch with the same
key, but forkBranch stamps idempotency_key into the params of EVERY branch
flavor — including session.branch_stored, whose params contract is
extra=forbid and did not declare the key. The desktop's right-click branch
(that spec's exact path) answered 4000 'idempotency_key: Extra inputs are
not permitted', no child row was created, and e2e/core/lineage-sidebar.spec.ts
failed at 'the branch created a session row' (0 rows, 60s poll).
- SessionBranchStoredParams: accept idempotency_key (optional; an older
client that omits it is unchanged).
- session.branch / session.branch_whole: accept idempotency_key and dedupe
via the same _idempotency_keys registry as session.create, so a
lost-response retry of a LIVE branch also returns the SAME child instead
of a duplicate (the desktop retry covers those flavors too, but the key
never reached the backend).
- _build_branch_agent records parent_session_id/branch_title on the child
runtime so the idempotent hit answers the same parent/title as the fresh
reply, without a DB round-trip.
- The session.create idempotent-hit early return now honors
copy_parent_history: a retried session.branch_stored answers
messages_omitted=true and never puts the copied transcript on the wire
(its result contract forbids 'messages', and re-serializing the parent
transcript through the renderer is what branch_stored exists to avoid).
- Regenerated gateway-contract artifacts; two use-session-actions tests
updated to expect the (opaque) key in branch params.
Red on main: tests/tui_gateway/test_protocol.py::
test_session_branch_stored_accepts_idempotency_key fails with the exact
4000 the e2e saw. Green with the fix, plus
test_session_branch_idempotency_key_dedupes_retry (server suite).
e2e lineage-sidebar passes locally on macOS (14-19s; CI PR run failed at 1.2m).
Two issues from the hermes-sweeper review:
1. Notification bypassed readableError normalization. The catch block
called notify() with raw err.message, skipping wrapper stripping,
detail extraction, and the >180-char long-message fallback. Switch
to notifyError(err, fallback, action) so the retry action rides the
established normalization path. notifyError gains an optional third
action parameter (backward compatible).
2. Retry was unsafe against a lost response. tui_gateway/server.py
session.create generates a fresh UUID per call; if the first create
committed but its response was lost in transit, the user's retry
would spawn a duplicate child session. Add optional idempotency_key
support: the server dedupes on (key, live session), returning the
original sid for a retry, falling through to a fresh create if the
original session was closed in between. forkBranch generates a
stable key per logical branch attempt and passes it through the
retry action so both attempts share it.
Tests:
- tests/tui_gateway/test_protocol.py: 3 new tests cover dedupe, no-key
backward compat, and key expiry when the session is closed. Full
protocol file: 86/86 passed.
- use-session-actions.test.tsx: 2 new tests cover key reuse across
retry and readableError normalization on the failure notification.
Full file: 21/21 passed. tsc --noEmit: 0 errors.
- scripts/check-windows-footguns.py on changed files: clean.
A Bot Mode group turn blocked on the user's clarify/approval answer pushes its
runaway hard cap out instead of expiring the card under the room; the offline
toast and the Settings description stop promising an approval timeout; the
approval.respond deadline comments describe what it bounds now.
One Skip broke a tester's whole onboarding. The provider named every
tool call "call_0", and the desktop bound each setup card to its
request by call id alone. After the apps card was skipped, every later
card with "call_0" showed that old "Skipped" answer instead of its
picker. Composer text was also saved as a pick, so "hey" became a
plugin and a complaint became the layout.
- The agent renames a tool call whose id is already used earlier in
the session (call_0 -> call_0_d2), before the call is stored or run.
Ids already in history never change, so the prompt cache holds.
- The card's request binding is keyed by session, message and call id.
- Composer text on a card with fixed rows counts as a pick only when it
names a row by id or label. Otherwise setup_choose returns
outcome "typed" with the words in `said`, records no card facts, and
tells the model to reply and send the card again. The settled card
shows the typed words.
With skills.write_approval on, every skill write is staged to the
file-backed pending store, but no non-CLI surface could review it:
the desktop resolver marked the whole /skills family as the sidebar's
(desktop="settings") and discarded the subcommand token, and
tui_gateway command.dispatch had no route for /skills or /memory —
a failed slash worker turned both into the routing refusal, so staged
writes accumulated silently (#98330, #123315, #118812).
- hermes_cli/commands.py: new CommandDef.desktop_subcommands field;
/skills declares its write-approval review slice
(pending/approve/reject/diff/approval) and drops desktop="settings".
command_desktop_meta serializes the allowlist (empty tuple = deny
all); gateway contract regenerated.
- apps/desktop: the resolver threads the typed arg —
desktopSubcommandAllowlist, an exec gate
(desktopSubcommandUnavailableMessage) that renders an actionable
refusal for hub mutations instead of forwarding them, completion
filtering so the popover never suggests a dead end, and the
isDesktopSlashCommand(name, arg) threading at the runExec gate.
An explicit /skills spec keeps a current Desktop able to review
staged writes even against an older backend catalog.
- tui_gateway command.dispatch: _cmd_skills answers only the
registry-declared review slice under _session_home_scope (an
unlisted subcommand keeps the 4018 routing refusal); _cmd_memory
routes the shared handler with the live agent's store or a fresh
on-disk one and a profile-scoped config setter. Gate-off still
answers while staged writes exist (gateway parity, never stranded).
- tools/write_approval: _staged()'s review hint is surface-aware —
slash-capable surfaces (CLI/TUI/desktop/chat gateway) keep
"<command> pending"; headless contexts (cron, kanban, api_server,
webhook delivery) get the pending directory path instead of a
command nobody can deliver.
Batch skill writes also rendered "(batch on ...)" for /skills diff —
skill_pending_diff had no action='batch' case; ops now diff in order
against what earlier ops of the same batch left (finn763's #123861).
Supersedes #98794 (registry-driven desktop_subcommands + resolver
allowlist), #105773, #118821 (command.dispatch handlers + arg
threading), #123861 (batch diff renderer). #107799 is superseded by
the same subcommand-aware resolution.
Co-authored-by: foma-agent <foma@bokonon.ai>
Co-authored-by: Jason Havenaar <35441875+jasonHav@users.noreply.github.com>
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Co-authored-by: chelsealong <chelsealong@126.com>
* fix: setup cards tell the model the name the user saw, and an empty apps or plugins list shows no card
The accent card showed "Ultraviolet", but the result only carried "#8a2be2", so
the model guessed a CSS name ("BlueViolet, done."). The card now answers with the
label of each picked row next to its id, the backend passes it through for the
app-owned lists it cannot name itself (accent, theme, layout, connectors,
plugins), and the tool description tells the model to say the label.
A session with no Nous identity (own key, guest sign-up rate limited at boot)
got an apps card that said "This list isn't available right now". Now
setup_choose kind:"connectors" checks connectors_available() in the setup
session's own profile scope before any card: with no identity and the free tier
on, it makes one guest sign-up attempt through ensure_portal_identity (the mint
memo's cooldown still applies), and if connectors are still closed it returns
no_answer with a one-line notice and the next card, without showing a card. An
empty plugins list returns the same way. The skill says one line about the free
Nous account and moves on.
The renderer re-reads a cached "unavailable" connector catalog when the card
mounts, so a guest minted by the card is not hidden behind the prefetch taken at
the start of setup.
* fix: the setup intro holds the thread until its spring settles
The backend prelude writes the first assistant row and the name card while the
intro copy is still typing. The copy's cover uses the chat surface colour, which
is transparent under glass, so the card showed through mid-motion. While the copy
plays ($introHoldsThread, true only for the 'playing' stage), the thread under it
is hidden; it shows when the spring settles, and at once with reduced motion
(the stage goes straight to 'landed'). The backend prelude is unchanged: its
tool call waits for the answer.
The count left out DISPLAY_VISIBLE_SQL, so model-only rows from
micro-compaction were counted while the reader painted none, and the history
wait this count exists to skip still ran. SessionDB.display_message_count now
counts the same set _display_rows_from_conn paints, through _read_one instead
of reaching into db._lock/_conn, and one _live_count_field serves all three
listing paths. The field is declared on SessionListRow, ProfileSessionPreview
and ProfileCanonicalSession (extra=forbid) and the desktop derives it from the
generated contract instead of hand-typing it twice.
* feat(setup): the backend fills the tour and fork cards and preselects scanned apps
The welcome model fills only the required parameters of setup_choose, so
cards that needed options arrived as bare text boxes in play 4. options is
now required ([] means the app's list, or free text for a question).
Two new kinds, tour and fork, take their rows from the backend whatever
the model sends. The fork rows come from the facts the /initiate-setup turn
embedded, recorded next to the setup state; "Something else" on a
machine-first fork opens the rest in the same call. The connectors and
plugins cards start with the apps the scan saw in use, and Blender when it
is here, already picked. The result names a picked row (label), so the
settled card shows labels for rows that are not in the call's args.
* fix(setup): the setup skill answers honestly about the scan and always reaches a handoff
Card lines carry options and use the app-owned tour and fork kinds. When
asked what Hermes knows, the model answers from the facts and names the
scan instead of denying it. A skipped fork goes to a first-task card built
from the scan, a second skip offers one concrete task, and a card left for
an off-flow question is re-sent. Dead fork-building and fallback text is
cut, which keeps the skill under 25 KB.
* fix(setup): card facts per setup conversation; the backend steers each beat and carries the handoff
The saved card facts were one file per setup profile, so a second
/initiate-setup overwrote the first conversation's fork rows. They are now
keyed by the session id under the bound Hermes home, and setup_choose reads
its own session's block. A fork card with no recorded facts (skill loaded
through the inline-shell hook) collects them fresh.
setup_choose now returns `next` for the flow's following step (the next
card's exact arguments, the skip ladder from the fork on, a re-send of the
pending card after typed off-flow text) and, from the fork, `handoff`: the
message parts and the plan, cut to this machine, from templates/handoff.md.
That moves about 6 KB of handoff and plan text out of the first turn.
Rows the model sends for tour, fork and the new machine_use kind are
dropped before validation, so a malformed copy can no longer fail the card.
The connectors and plugins cards are always multi-select, so preselected
rows are never lost to a single-select confirm. The off-desktop voice rule
is back in the skill.
* docs: regenerate the initiate-setup skill page
* feat(initiate-setup): one "I noticed" line before the fork shapes the first tasks
* P18b: the first-run intro boots into the setup chat and sends /initiate-setup hidden
- The kickoff stops seeding the old runbook. When ensure_setup_session reports an
empty setup chat, the app sends /initiate-setup through the normal slash-to-skill
path with a hidden user row, so the thread shows nothing for it, live or after a reload.
- /initiate-setup rows project to '/initiate-setup' (backend and the shared TS twin),
not the 36k skill body.
- Intro view state in memory (off | starting | intro | ended). The boot overlay is the
starting screen. The unlisted 'demo' layout (chat only, narrower column, minimal
composer, no status bar) is applied while layout persistence is held, so it is never
saved as the user's layout.
- The intro copy types centred, holds, springs up and gives way to the backend's first
assistant row (same words). Reduced motion jumps to the end.
- Leave actions (sidebar, another chat, profile, layout pick, Cmd-K command) end the
intro, grow the window, restore the layout, mark seen and send new chats back to the
launch profile. The intro pauses tips and holds the provider picker and free-tier screen.
- Skip switches to the launch profile, marks seen and retires the tutorial and
local-model tips.
- A setup start_chat success ends the guided phase (free-tier chip, consent, completion
metric) and shows the handoff tour when tours are on.
- Settings > Advanced > Developer: Reset onboarding (reset RPC, then reload).
* P18b fixes: keep a layout picked in the intro, main window only, relaunch drives a cut-off opening
- A layout pick (card, apply_layout, picker) ends the intro on the picked layout and saves it.
- Leaving pins new chats to the launch profile by name, not to the setup profile.
- Only the main window runs the intro: not peers (Cmd-Shift-N) or watch windows.
- The window takes the demo shape only after setup.status confirms inference.
- starting has a 45 s deadline that records a failed start; a late kickoff undoes itself.
- A relaunch whose setup chat has no live turn and no model words after the last answer
sends /initiate-setup again (hidden).
- The setup chat is a normal chat once the intro has ended.
- The demo flag lives in store/onboarding-intro.ts so the shell does not import layout code.
- Reset onboarding goes through the ambient gateway requester and brings the tips back.
- The describe_skill_invocation backend hunk stays with P18a.
- Delete SETUP_CHAT_TITLE and the guidedGreeting strings.
* fix: keep the merged /initiate-setup row display from the prelude PR
* fix(setup): pills are the only question layout, no key letters on setup pickers, no System theme tile
The focused pill shows its detail as one line under the pills. Setup cards no
longer bind letter or digit shortcuts; arrows and Enter still work. clarify
cards keep their key badges.
* feat(setup): handoff card shows the sidebar title and retries a failed start_chat
The card title follows the new chat's sidebar row. A rejected start_chat shows
"Couldn't start that chat." with Retry, which runs the same request again
through the new session.start_chat RPC from the calling session.
* fix(setup): Retry on the handoff card is durable, told to the model, and offered only when it can help
- start_chat rejections say whether the same arguments can succeed (retryable); the card offers Retry only then,
only while the chat is idle, and not once a later start_chat in the chat started.
- session.start_chat takes the rejected call's tool_call_id and needs an explicit session: a started retry is
recorded on that tool row's display_metadata (the model's replayed row is unchanged), a second Retry returns it,
a reload shows it, and the next user turn tells the model once (the reactions pattern).
- A chat that started but could not open toasts "Couldn't open the chat", not "Couldn't start that chat."
- The theme card drops System whoever supplies its options.
- Question pills: the cursor follows focus and hover, pills with a detail carry a dot, the detail line has a title.
* MEDIA: tags parsed once, in the backend; clients get clean text plus attachments
tui_gateway runs the gateway's extract_media on the live stream (a tag's
line is held until it completes, then the file goes out on that delta),
on message.interim/message.complete, and on history through the shared
history projection (session.resume and the REST messages route). The wire
contract gets an attachments list on message.delta/interim/complete,
TranscriptMessage and InflightTurn.
Desktop renders attachments as data parts (media player or file card, HTML
included) and in group rooms; the TUI lists them as link rows. The three
renderer MEDIA: parsers (chat parts, speech text, Artifacts) and the TUI
MEDIA line parser are deleted. Stored rows keep their raw tags.
* Delete the ::preview directive and its prompt text
The inline HTML widget, its registration and its hidden-turn talk-back go;
an HTML file delivered with MEDIA: is a normal file card with preview. The
desktop and TUI platform hints now describe attachments. The
transcript.directives area stays for ::onboarding and ::ask.
* MEDIA stream splitter works per line; group mirrors and sync keep files; TUI shows a file row when its line completes
- MediaStreamSplitter splits each completed line once, carrying code-fence state, so
prose that says "media:", blank-line runs and [[audio_as_voice]] no longer stall
the stream, and cost stays linear in reply length.
- The partial-tag hold applies only at a token start (or to an upper-case prefix),
so words like "stream" are not held.
- Subagent child mirrors stream through a splitter too, flushed at tool and end.
- Group rooms: externally written rows and the ui_meta sync copy carry attachments;
the echo guard compares files as well as text.
- TUI: a delta that brings a new file seals the text so far with its file row.
* fix(setup): manage_catalog leaves the setup chat; installs go to the caller's profile
- The setup profile disables the catalog toolset. The desktop session
folded it in as a client-surface toolset, so manage_catalog was still on
the setup chat's eager tool list.
- Delete default_target_profile: a catalog install and search target the
calling chat's own profile, the setup profile included.
- /initiate-setup still needs "the launch profile when run from the setup
home"; that moves to hermes_cli.setup_profile.primary_profile().
- The manage_catalog description and the reference docs drop the setup
chat exception.
* feat(setup): drop setup_choose intent; apps and plugins cards say they set up later
- setup_choose loses `intent` (the now / when needed / just note it choice)
in the tool, the wire contract and the cards. The result is
{outcome, picked}. Contracts regenerated.
- The apps and plugins pickers carry one footnote, "We'll set these up
when you start.", replacing the plugins-only "Nothing connects or
installs yet" note. Other locales translated; the desktop key list is
regenerated (it was also behind en.ts on appTour and sharedMetrics).
* fix(desktop): an install card stays drawn when a connect card follows it
The renderer keeps one connection operation per session. When a turn asks
twice (a manage_catalog install card, then a manage_connections connect
card), the second operation replaced the first in the store, and the
settled install card fell back to a plain tool row.
A settled operation that the next one replaces now moves to a store keyed
by its tool call, and the catalog and connector cards read their own row's
operation from there. Both cards stay stacked in the turn.
* fix(setup): setup_choose inline calls stop passing intent; /initiate-setup reads the launch home at call time
The inline executor still mapped intent into setup_choose_tool, which no
longer takes it, so every desktop setup_choose call raised TypeError.
primary_profile now gets server._launch_home() instead of the import-time
_hermes_home.
* fix(desktop): keep one card per kind: the latest install card and the latest connect card
The replaced-operation cache kept every settled operation by tool call, so
consecutive manage_connections calls drew one connect card each. It now
keeps, per session, only the settled operation of the other kind (install
vs connect) that the current one replaced. An install card and a connect
card stay stacked in either order, and a later call of the same kind still
takes over its card. clearConnectionRequest clears both caches, and the MCP
setup card reads the same per-row selector, so sessionConnectionRequest is
gone.
The usage-stats offer read the focused profile's consent, so it showed in
the setup chat (the hermes-setup profile) and again in the first task chat.
The setup profile's answer counts for nobody.
onboarding.state now reports the setup profile's name. The consent host
waits until guided onboarding is settled (the backend's intro is read and
the phase is not pending, guided or handoff; skip and handoff both mark the
intro seen) and never reads consent while the setup profile is focused.
The copy drops the jargon (bounded counters, consent window, install ID):
a short strip, a 'Usage stats' details dialog with a collapsed 'What is
counted' list checked against the shared-metrics contract, one paragraph
on what is sent, and matching Settings text. Other locales keep their old
text under the renamed keys.
* feat(tour): gui_tour start with no steps runs the app's built-in tour
The app owns the stops and their copy. preset quick shows the three
essentials; full adds the model picker and what the interface mode shows.
The model picker step says this computer can run a local model when the
local-setup tip's fit check says so.
* docs(setup): the tour beat is one gui_tour call
Quick tour and Show me everything map to preset quick and full. targets stays
for a custom tour of one thing the user asks about.
* fix(setup): /initiate-setup fills the host facts hook in place
The builder read SKILL.md raw, so the prompt kept the literal inline-shell line
and carried the host facts again in the session block. It now replaces the hook
line with the facts it already collects in-process, and the session block holds
only session facts.
Check follow-ups.
- onboarding.ensure_setup_session is back. It creates or finds the
setup chat by title, so every call returns the same chat and whether
it is empty. It takes optional seed messages and writes them only
into an empty chat.
- The kickoff now seeds through it instead of its own titled
session.create, and adopts a title hit only when it has messages. An
empty row no longer breaks the title lookup; the kickoff seeds it.
- The setup config no longer lists "start_chat" in
platform_toolsets.cli. The toolset does not exist on this branch, so
config validation and CLI startup warned about an unknown toolset.
Review follow-ups.
- The setup config now writes agent.disabled_toolsets with "catalog"
(merged with the cloned list), so the client-surface catalog toolset
does not fold into the setup chat once it exists.
- It pins agent.coding_context to "off", so a cloned "focus" posture
cannot swap in the coding toolset in a code workspace.
- It replaces tools.tool_search instead of merging into the cloned
block. A cloned "enabled: off" no longer turns the defer list off,
and the bool form no longer raises and deletes the new profile.
- onboarding.ensure_setup_session is removed. Nothing called it, and
its empty row would break the kickoff's title lookup. It returns
with the intro view, which can seed an existing chat.
- requestDesktopOnboarding and the free-tier ready screen wait until
the gate has read onboarding.state, so they no longer see "idle"
before the guide starts.
The setup profile's marker now holds the onboarding state:
{intro: unseen|seen, failed_starts: 0..3, completed_at?}. New RPCs read
and change it: onboarding.state (plus eligible, read from
HERMES_GUEST_ONBOARDING only), onboarding.record_failed_start (the third
failure marks the intro seen) and onboarding.mark_seen.
onboarding.reset_setup_profile clears it. onboarding.ensure_setup_session
creates or finds the setup chat by title and writes its row at once, so
every call returns the same chat. The renderer does not call it yet; the
kickoff keeps its seeded create until the intro view lands.
The setup profile's config.yaml is now written, not appended to:
platform_toolsets.cli = [setup, start_chat, no_mcp], a hand-written
tools.tool_search.defer list that keeps apply_layout, setup_choose and
start_chat eager, and display.show_reasoning = false. Creation and reset
no longer copy MEMORY.md and USER.md from the launch profile, because
the built-in store loads them even with the memory toolset off.
The renderer no longer keeps the phase in localStorage. The gate seeds
from onboarding.state, Skip and the app handoff mark the intro seen, and
a failed start is counted instead of saved as "skipped" forever. A start
without inference turns the guide off for this launch and counts
nothing. The duplicate PERSONA prompt block is gone; SOUL.md is the only
copy.