Commit Graph

686 Commits

Author SHA1 Message Date
Teknium 77fcc2ea31 feat(display): honor display.timestamps across desktop transcript and TUI
One config key everywhere (#41531): the same display.timestamps that stamps
[HH:MM] on classic-CLI labels now gates the desktop transcript's timeline
timestamps and renders dim [HH:MM] labels on TUI user/assistant rows.

- desktop: $displayTimestamps store fed from config.yaml via
  use-hermes-config; TimelineTimestamp renders nothing while the key is off
  (the default). Hover tooltips with the exact time stay ungated (#70450).
- TUI: tui_gateway forwards each persisted row's timestamp in the display
  projection; toTranscriptMessages threads it as Msg.createdAt; live rows
  are stamped at append (the #82840 rule); MessageLine shows a dim [HH:MM]
  above user/assistant rows when display.timestamps is on.
- No new config keys, no HERMES_* env vars; display-only, prompt-cache safe.
2026-08-15 01:04:19 -07:00
embwl0x e89532d97e fix(desktop): order async session git metadata 2026-08-15 00:33:11 -07:00
thatssoheil 343068eb10 fix(petdex): cover the CLI pet pane and status-line signature too
Review follow-up: _pet_resolve_config (cli.py, the CLI pet pane gate) and
_pet_sig (tui_gateway/server.py, the status-line 'off' signature) read
display.pet.enabled with bare bool/truthiness, so a quoted 'false' still
enabled the mascot on the CLI surface. Route both through is_truthy_value
(default False) like the other three sites.
2026-08-15 00:32:44 -07:00
thatssoheil 9e8828999d fix(petdex): quoted 'false' now disables display.pet.enabled everywhere
Three bare bool() reads of display.pet.enabled (deep-merged config, so a
hand-edited quoted YAML value lands as the string 'false'): the pet.cells
gate, the pet.gallery enabled echo, and the shared pet-state helper.
bool('false') is True, so a quoted value kept the mascot enabled against
the operator's explicit intent.

All three now go through utils.is_truthy_value (default False, matching
DEFAULT_CONFIG). Regression test drives pet.gallery with a quoted 'false'
config and asserts enabled=False; verified RED on the old code.
2026-08-15 00:32:44 -07:00
Teknium fbaea9bddc feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable) (#86797)
* feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable)

Adds a source-orthogonal, archive-orthogonal 'hidden' session flag meaning
'don't show in the global Sessions sidebar, but stay fully resumable by the
surface that owns it'. Mirrors the existing archived/pinned capability end to
end, so it's a generic widening (any plugin that owns its own session lifecycle
- kanban, Bot Mode, future plugins - can keep its sessions out of the shared
recents list) rather than a per-plugin special-case.

- Schema: hidden INTEGER NOT NULL DEFAULT 0 on sessions (additive; lands on
  existing DBs via the declarative _reconcile_columns ADD COLUMN path, same as
  archived/pinned - no version-gated migration).
- DB: SessionDB.set_session_hidden(session_id, hidden) (clones set_session_pinned
  incl. the compression-lineage recursive CTE); list_sessions_rich gains
  include_hidden=False, appending 's.hidden = 0' by default so hidden rows drop
  from every listing path (and the REST sidebar endpoints inherit it with no
  change).
- Gateway: session.set_hidden RPC (mirrors session.title); session.create accepts
  hidden=true, deferred via pending_hidden and applied in _ensure_session_db_row
  when the row is lazily created (mirrors pending_title).
- REST parity: PATCH /api/sessions/{id} accepts+bool-validates 'hidden' ->
  set_session_hidden; _session_response exposes it.

Enables Hermes-Bot-Mode to hide canonical 'Bot Chat' sessions from the sidebar
(NousResearch/Hermes-Bot-Mode#46) WITHOUT retagging source (which would mis-set
the agent platform). Bot Chats keep source=desktop. Gateway RPC needs a
SERVE-backend restart to take effect live. 1 focused test (default-exclude /
include_hidden / unhide round-trip).

* fix: teach lost-and-found recovery about the 55-column sessions layout

Adding the 'hidden' column makes the current sessions table 55 columns. The
SQLite lost-and-found recovery classifier keys off the physical field count
(SESSIONS_LAYOUT_NFIELDS) to identify a salvaged sessions row, so a recovered
current-layout row (nfield=55) would otherwise be unrecognized and dropped.
Add 55 to the frozenset (54/52 stay as historical prefixes) and update the
column-count assertions + synthetic current-layout insert in the recovery test.

---------

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-15 00:31:37 -07:00
joaomarcos 39e480c051 fix(state): close leaked SessionDB connections on exception paths (#83226)
SessionDB could leave native SQLite handles open when construction failed
partway through schema/pragma/FTS/repair/lock/interrupt handling. Other
short-lived callers (MCP reads/polling, session search, reactions, trace
upload, insights, shutdown recovery) opened temporary SessionDB handles
without a complete ownership boundary. API-server profile caches and
RetainDB shutdown had similar late-close races. Under sustained load this
exhausted file descriptors (EMFILE).

- Close partially initialized SessionDB connections on every constructor
  exception path via a finally block guarded by an initialization-complete
  flag.
- Close temporary/cross-profile SessionDB handles in finally blocks across
  CLI, MCP, search, trace, reactions, insights, and recovery paths.
- Add API-server per-profile cache ownership and disconnect cleanup.
- Make RetainDB writer-queue shutdown exception-safe: track connections per
  thread, close on worker exit, reject new enqueues after shutdown starts,
  and sweep any connections left by short-lived threads.
- Add regression coverage for constructor failures, worker-thread readers,
  API disconnect failures, shutdown recovery, RetainDB late enqueue, and
  foreign-loop async clients.

Salvage notes: the original PR's per-thread WAL-reader ownership changes
were superseded by main's read-connection pool (permits + checkout/return);
its cron timeout-abandon fix is credited separately to #72822's earlier
identical fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:41:26 -07:00
Fangliquan 86a8928711 fix(tui_gateway): read profile DB for live session display payload
Warm/live reuse was hard-coding the launch SessionDB, so app-global remote
profile sessions fell back to collapsed in-memory history and dropped
verification candidates that eager profile resume still showed.
2026-08-14 21:25:03 -07:00
Teknium 5a3b593230 fix(desktop): order mid-turn user messages after the assistant output that predates them
A message typed while a turn streamed rendered ABOVE assistant output the
user had already watched arrive (#73793), and the retired
insert-before-the-active-reply fallback could splice the bubble mid-thread
— halfway up the chat — when the stream id was missing or stale (#83151).

Fix the class at every path that assigns a transcript position to a
mid-turn user message:

- New shared appendMidTurnUserMessage (rewind.ts): seal the live stream
  bubble in place (interim), append the correction at the live tail, and
  clear streamId so post-redirect deltas seed a fresh bubble BELOW the
  correction. Used by both the primary composer redirect path
  (use-prompt-actions) and the session-tile steer path
  (session-tile-actions), replacing the insert-before splice and its
  last-assistant mid-thread fallback.
- appendLiveSessionProjection now projects the resume/reload turn in
  arrival order (prompt → streamed output → correction → post-redirect
  output) instead of prompt → corrections → reply, so the projection
  agrees with the live transcript and messages no longer jump upward on
  reconnect. With the gateway's new correction_offsets the flat dump is
  split at each accepted-correction boundary; without offsets the
  corrections follow the projected reply.
- tui_gateway/server.py records correction_offsets (assistant text length
  at each accepted correction) on the inflight turn and carries them in
  _inflight_snapshot, only when complete, so resume can rebuild true
  arrival order. Older gateways/clients degrade cleanly.
- preserveLocalPendingTurnMessages and the projection's latest-user-run
  matcher now treat a live-tail assistant row between the prompt and its
  correction as part of the same turn's run, so arrival-ordered runs
  survive refreshes without dropping the prompt.

Fixes #73793. Fixes #83151.
2026-08-14 20:25:07 -07:00
Michael Huang 2cabeba563 fix(tui,desktop): refresh context usage live during active turns 2026-08-14 20:24:42 -07:00
Teknium 73bcfddb3d fix(desktop): replay pending clarify prompts after reconnect
Clarify prompts share the same emitted-while-detached failure class the
pending-approval replay fixed: `clarify.request` rides `_block()`'s pending
registry, so a client whose transport was down when the event fired never
sees the question and the agent thread stays parked until timeout.

Widen the resume snapshot the same way:

- tui_gateway/server.py: `_live_session_payload` now carries
  `pending_clarify` — a read-only snapshot of the clarify prompt still
  blocking the session, scoped to the owning runtime sid. The registry stays
  authoritative; the embedded request_id resolves via clarify.respond.
- Desktop resume paths (`use-session-actions`) restore the parked clarify
  into the clarify store (multi_select preserved) and flag needsInput,
  mirroring restorePendingApproval on both the activate and resume paths.
- pending_approval replay now also forwards the queue-injected request_id so
  the restored prompt responds with exact-request correlation.
- Tests: server-side replay + scoping test; harmonized the #82087 replay
  test with the request_id `_ApprovalEntry` now injects.
2026-08-14 20:24:32 -07:00
konsisumer 38b9005b95 fix(desktop): replay pending approvals after reconnect 2026-08-14 20:24:32 -07:00
Teknium 4ac12d53fb fix(tui_gateway): keep steer-mode fall-through bursts queued instead of interrupting
Desktop/TUI busy-input `steer` mode escalated any fall-through message
(steer() rejected, raised, or a non-steerable multimodal payload) into a
hard interrupt of the live turn. AIAgent.interrupt() also clears the
pending steer buffer, so a burst of user messages sent while the agent
was busy could be silently destroyed: earlier successfully-steered
messages were dropped from the buffer and the live turn was killed.

Steer-mode fall-throughs now keep pure queue semantics: preserved FIFO
in queued_prompt/queued_prompts and drained on turn end, per the
existing steer contract. Only explicit `interrupt` mode still fires
_interrupt_busy_session. No synthetic user messages are injected
mid-loop; accepted steers continue through the sanctioned OOB
steer-marker path.

Regression tests cover: rejected steer queues without interrupting,
steer exception falls back to queue, multimodal payload queues, a mixed
burst preserves accepted steers plus the queued fall-through, and a
fall-through burst drains all texts FIFO after turn end.

Fixes #86134
2026-08-14 20:24:06 -07:00
StanleyStetson 3995fd434c fix(tui_gateway): stop replaying live-turn user text after redirect
A mid-turn correction (Desktop session.redirect / busy-input interrupt redirect)
must not leave a server-queue self-copy of the live inflight user prompt.
Otherwise post-turn _drain_queued_prompt restarts that original text as a
fresh agent turn after Q completes (#84417).

Scrub text-only self-duplicates of inflight_turn.user on successful
redirect/steer, refuse admitting them in _enqueue_prompt, rewrite merged
"{P}\n\n{Q}" slots to Q-only, bump _queued_prompt_generation on compression
session rotation, and restore the claimed queue envelope when generation
cancels mid-drain. Stabilize profile-scoped agent-build unit tests under CI load.

Fixes #84417
2026-08-14 20:24:06 -07:00
Teknium f92accd05d fix(tui): lead slash_worker PATH with Hermes' managed bin dir
Follow-up on the #83854 salvage: prepend $HERMES_HOME/bin ahead of the
venv and user-local bin dirs, matching the managed-first Browser Use
CLI resolution policy — the worker resolves the same canonical binary
the agent process does.
2026-08-14 16:22:47 -07:00
webtecnica 73b4e41ab2 fix(tui): prepend Hermes venv and user-local bin to slash_worker PATH (#83845) 2026-08-14 16:22:47 -07:00
Teknium d275b96bfd feat(gateway): per-profile MCP server lifecycle RPCs (mcp.servers.*) (#86473)
Adds the full MCP setup surface as profile-scoped gateway RPCs so a
desktop client (Bot Mode's bot editor, the core Capabilities tab) can
add/configure/test/authenticate/remove MCP servers for ANY profile, not
just the launch profile:

- mcp.servers.list (profile) -> configured servers (transport, auth,
  oauth_tokens_present, enabled, tool names; no secret values)
- mcp.servers.add (profile, name, config|preset, bearer_token?) -> reuses
  mcp_config._apply_mcp_preset / _save_mcp_server / _save_bearer_auth_token
- mcp.servers.set_api_key (profile, name, value, env_var?) -> http auth
  header template or stdio env ref, via save_env_value
- mcp.servers.test (profile, name) -> _probe_single_server + oauth state
- mcp.servers.remove (profile, name)
- mcp.servers.oauth.start/poll (profile, name[, session_id]) -> mirrors the
  PROVIDER oauth session/poll model (not the FastAPI dashboard flow): a
  background worker drives the same interactive machinery 'hermes mcp login'
  uses, capturing the browser redirect on a local loopback listener. Client
  opens auth_url via openExternal and polls until status=='approved'.

All handlers are profile-scoped via set_hermes_home_override in try/finally
(mirrors skills.manage). Shared helpers live in tui_gateway/mcp_rpc_helpers.py
and are aliased onto server.py's namespace so the rebound handler bodies
(HandlerRegistry.install) can resolve them — a plain def in methods_tools is
unreachable post-rebind. Reuses hermes_cli/mcp_config.py throughout; no config
logic duplicated; no raw yaml near config.yaml (config-read-guard safe).

Tests: tests/tui_gateway/test_mcp_profile_rpcs.py, 8 E2E against real temp
HERMES_HOME profiles asserting add/list/set_api_key/remove land in the RIGHT
profile's config.yaml and not the launch profile's. 8/8. Registration +
live mcp.servers.list verified in an imported gateway.

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-14 16:17:22 -07:00
Teknium 452465bf78 fix(tests): reap leaked TUI notification pollers between tests
test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading
failed twice in one hour on CI slices for two UNRELATED PRs (86371,
86374) with `assert set() == {proc_batch_2, proc_batch_3}`. Root cause:
session.init/create tests earlier in the file start real per-session
notification poller daemon threads and never stop them. Those pollers
outlive their test and keep polling the PROCESS-GLOBAL
process_registry.completion_queue, stealing-and-requeuing the target
test's events mid-assertion so its bounded drain loop can starve.

Reproduced: with 30 leaked foreign-session pollers injected via a
sabotage conftest, the target test fails standalone ~1 in 3 runs with
the exact CI assertion; with the reap fixture active it passed 8/8
under the same sabotage.

Fix:
- tui_gateway/server.py: _start_notification_poller registers
  (stop_event, thread) in module-level _notification_pollers (pruned of
  dead threads on each spawn; threads get a stable
  tui-notif-poller-<sid> name for debugging).
- tests/test_tui_gateway_server.py: autouse fixture sets every
  registered live poller's stop event after each test and joins them
  under ONE shared 3s budget (the poller loop wakes at least every
  0.5s), so no poller survives into the next test. No per-thread
  timeout, no session-dict mutation — a first draft that mutated
  session state and joined per-thread hung the file; full-file runtime
  with this version is 15.2s vs 13.2s baseline.
2026-08-14 16:04:10 -07:00
Max Hsu 308bb56349 fix(gateway): bind profile override in _persist_live_session_system_prompt
Fixes #50233

_persist_live_session_system_prompt rebuilds the system prompt after a
live model switch (/model), but _start_agent_build's finally block has
already reset set_hermes_home_override by then. load_soul_md() and
build_skills_system_prompt() call get_hermes_home() which falls back to
the root ~/.hermes, loading the wrong SOUL.md identity and skills for
the session's profile.

Fix: set_hermes_home_override(session["profile_home"]) before calling
agent._build_system_prompt() in _persist_live_session_system_prompt,
and reset it in a finally block. Also upgrade the failure log from
DEBUG to WARNING so silent profile-wrong-prompt issues are visible.

The first-prompt lazy-build path is unaffected — _run_prompt_submit
already re-sets the override before run_conversation. The /model slash
worker path does not, which is the gap this fixes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 15:08:41 -07:00
teknium1 f79440e0f4 feat: /loop — recurring in-session wakeups (Claude Code parity)
Ports Claude Code's /loop (and its /proactive alias) across every Hermes
surface. /loop [interval] <prompt> re-runs a prompt or slash command on a
recurring cadence inside the live session; omitting the interval enables
self-paced mode (starts at the floor, backs off exponentially while the
agent's replies stop changing, snaps back on change — local digest
comparison, zero extra LLM cost).

Stop conditions: agent-emitted LOOP_COMPLETE marker, --times N,
--until <condition> (judged by the existing goal_judge aux task,
fail-open), /loop stop, and a loops.max_ticks backstop budget.

Core: hermes_cli/loops.py (LoopState + LoopManager + shared
dispatch_loop_command), persisted per session in SessionDB state_meta
(loop:<sid>) so /resume picks it up; migrates across compression
boundaries like /goal. New SessionDB.list_meta_prefix() powers the
gateway's cross-session scan.

Surfaces:
- CLI: /loop handler + idle-fire and post-turn-complete hooks in
  process_loop (mirrors the /goal hook shape; Ctrl+C pauses the loop)
- Gateway: /loop handler with route capture, mid-run control-verb guard,
  post-turn tick completion, and a supervised loop_wakeup_watcher that
  injects due wakeups into idle chats via the synthetic-message path
- TUI/dashboard/desktop: command.dispatch handler + per-session
  notification-poller wakeup driver + post-turn completion in the turn
  dispatcher; /loop added to the desktop slash palette
- /goal mixing: an active non-parked goal owns the idle boundary — loop
  ticks defer until it finishes, pauses, or parks; real user input always
  wins over both

Config: loops.{min_interval_seconds,max_ticks,self_paced_floor_seconds,
self_paced_ceiling_seconds}. Docs page + sidebar entry. 77 new tests.
Slack's 50-slash cap: /version moves to /hermes version to free the
native slot for /loop.
2026-08-14 13:40:19 -07:00
Brooklyn Nicholson 8b06f7df8e fix: attach RPCs no longer wait on the deferred agent build
image.attach, image.attach_bytes, file.attach, pdf.attach, clipboard.paste
and image.detach resolved their session through _sess(), which blocks on
_wait_agent(). None of them needs the agent — they read cwd/profile_home and
mutate attached_images, all populated when the session record is created.

None of these methods is in _LONG_HANDLERS either, so the wait ran inline on
the socket reader thread. Attach runs before prompt.submit, so pasting an
image into a session whose deferred build was still warming (MCP discovery,
model metadata, skills scan) stalled the send and every RPC queued behind it
on the same socket, with no spinner to explain it. prompt.submit already
resolves via _sess_nowait and waits later, off the reader thread — which is
why the symptom reads as "text is instant, images hang".

_sess_building() resolves the session and still kicks off the build (so the
following prompt.submit finds a warm agent), it just doesn't block on it.
_sess() is now expressed in terms of it, so the two differ in exactly one
way: the wait.
2026-08-14 13:24:40 -07:00
webtecnica 56a41715dc fix: persist provider on model switch and add billing_provider fallback
Salvage of #79604 (webtecnica) + #85721 (pierrenode), combined and
rebased onto current main with simplify-code findings folded in.

#79604: update_session_model() wrote the model name to sessions.model
but never persisted the provider into model_config. On resume, the
runtime recombined the persisted model with the config.yaml primary
provider (which may not serve that model), producing auth errors.
Fix: add optional provider parameter to update_session_model, merged
into model_config via the shared _merge_model_config_json helper (not
hand-rolled SQL). Wire both gateway /model call sites to pass
result.target_provider.

#85721: session_gateway_runtime() had no billing_provider fallback.
A CLI session that never ran /model has no gateway_runtime or
top-level provider in model_config — billing_provider (written on
every session's first accounted API call) is the only durable record.
Fix: add billing_provider as the last-resort fallback in
session_gateway_runtime(), filtering bare billing buckets (auto/custom)
that are not routable identities.

Simplify-code findings addressed:
- Use _merge_model_config_json instead of 40 lines of branched SQL
- Share _BARE_BILLING_PROVIDERS from hermes_state.py (was duplicated
  as a set in tui_gateway/server.py)
- Merge None-filtering from #85920 with the billing_provider fallback
  into one coherent return path

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
2026-08-14 14:39:08 +05:30
Teknium 9166530942 feat(models): unify selection-time guards into one registry across all surfaces
Adds hermes_cli/model_selection_guards.py: a single evaluation point that
runs every selection guard (cost + the new data-policy guard) and returns
the warnings that fired. All seven model-selection surfaces (CLI picker,
cli.py TUI modal, gateway typed /model, dashboard web_server, TUI gateway,
Telegram and Discord pickers) now call the registry instead of importing
model_cost_guard directly — so the data-training-tier warning from
PR #81416 fires everywhere at once, and future guards need zero surface
wiring.

Guard modules keep their public APIs; existing mock patch points
(hermes_cli.model_cost_guard.expensive_model_warning) remain valid.
2026-08-14 01:06:13 -07:00
kshitij cdea8214cb docs: fix stale parity claim in _BARE_BILLING_PROVIDERS comment
The set is no longer in parity with agent_init's fail-fast gate (which
still skips openrouter for a different reason: default route, not
unroutable). Say so instead of claiming parity.
2026-08-14 02:02:18 +05:30
liuhao1024 1c87772186 fix(tui_gateway): restore openrouter provider on session resume
BARE_BILLING_PROVIDERS incorrectly included "openrouter" alongside
"auto" and "custom".  OpenRouter is a fully routable provider with
its own API key and base_url — sessions that used OpenRouter store
billing_provider="openrouter", and dropping it forces resume to the
current global model (e.g. a custom endpoint), which is the wrong
provider for the stored model.

Remove "openrouter" from the bare-bucket set so OpenRouter sessions
correctly restore their provider identity on resume.

Fixes #57588
2026-08-14 02:02:18 +05:30
Dmytro Afanasiev b640e63035 fix(kanban): preserve TUI subscription after done 2026-08-13 12:21:04 -07:00
Teknium 7afac122ef feat: profile asset store (profiles.set_asset/get_asset) for avatars (#85530)
ui_meta (#85440) syncs compact roster metadata but is 64KB-capped
because it rides every profiles.list — image avatars stayed per-client.
set_asset writes a validated image (data URL or base64; PNG/JPEG/WebP
by magic bytes, 2MB cap, atomic write) to assets/avatar.<ext> in the
profile dir; get_asset returns it as a data URL on demand; profiles.list
gains a cheap has_avatar flag so rosters know to fetch without probing.
Server-side, so every client machine paints the same profile picture.
2026-08-13 11:36:40 -07:00
kshitij 3b3bda7b00 fix(gateway): pool-route wake.start/wake.status — same STT lazy-install chain
wake.start calls check_wake_word_requirements() → _stt_ready() →
_get_provider() → _try_lazy_install_stt() → ensure("stt.faster_whisper")
(same synchronous subprocess install chain as voice.toggle), and
start_listening() → _build_engine() whose constructors call
lazy_deps.ensure("wake.openwakeword" / "wake.sherpa" / …).
wake.status calls check_wake_word_requirements() too and is polled
by the desktop on every gateway-ready. Same bug class as #21123 /
#50005 — sibling to the voice RPC fix in the prior commit.

Update existing wake.start test call sites from server.dispatch() to
_dispatch_sync() since dispatch() now returns None for pool-routed
methods. Extend the pool-routing regression test to cover wake RPCs.
2026-08-13 22:44:23 +05:30
hustwkr 6a9d2dc2f3 fix(gateway): pool-route voice RPCs so STT lazy install can't block WS sends
voice.toggle (status) triggers check_voice_requirements() -> STT provider
auto-detect -> a synchronous faster-whisper lazy install (uv/pip subprocess
with a 300s timeout). Inline on the WS reader thread it stalls handle_ws
before it reads the next frame, so prompt.submit / session.list queued
behind a voice.toggle sit unread and the desktop 'send message' appears
dead for minutes while the install churns (reproduced: voice.toggle ->
session.list 40s+ timeout).

Route voice.toggle/voice.record/voice.tts to the RPC pool (same bug class
as #21123 / #50005) so a slow lazy install can't block message handling.
Adapt the voice handler tests to drive the handler inline via a small
_dispatch_sync helper (preserving transport-binding semantics) since
dispatch() now returns None for pool-routed methods, and add a regression
test asserting the voice RPCs stay pool-routed.
2026-08-13 22:44:23 +05:30
Teknium f795812c16 feat: profiles.describe/profiles.configure ws RPC for profile editors (#85216)
profiles.list/create (#85093) let plugins enumerate and create profiles
but not read or modify an existing profile's configuration over ws.
profiles.describe returns the full editor snapshot (description, SOUL.md,
model pin, per-skill enablement via the disabled-list model, per-toolset
enablement via the tools.enabled_toolsets pin); profiles.configure
applies any subset (description via write_profile_meta, soul, model via
_write_profile_model, disabled_skills replace-semantics via
save_disabled_skills, enabled_toolsets replace-semantics with empty-list
clearing the pin) independently and best-effort, reporting per-section
results. Both scoped via the HERMES_HOME override and pool-dispatched.
2026-08-13 03:12:10 -07:00
Teknium ccce6976e3 feat: image.generate ws RPC for plugin surfaces (#85183)
Desktop plugins reach the backend only through ws JSON-RPC; image
generation existed solely as a model tool, so plugin UI (avatar pickers,
artifact panes) could not generate images. image.generate delegates to
the configured image_generate backend, supports probe:true for cheap
availability checks, and returns the result as both the backend ref and
a size-capped data URL — remote-gateway clients cannot read gateway-host
file paths and hosted URLs are often CORS-opaque, so the data URL works
identically over local and remote gateways. Pool-dispatched; missing
backend degrades to a soft {available:false} instead of an RPC error.
2026-08-13 02:30:09 -07:00
Teknium 89a84e1ae6 feat: profiles.list/profiles.create ws RPC + plugin session-navigation doors (#85093)
Desktop plugins reach the backend exclusively through the generic ws
JSON-RPC door (host.request), but profile enumeration/creation only
existed on the dashboard REST router, which plugins cannot reach — so
anything 'one chat per agent profile'-shaped (bot rosters, profile
pickers, team panes) was impossible to build as a plugin.

- tui_gateway/methods_profiles.py: new @method handlers
  * profiles.list — profiles + optional last_session preview per profile
    (mirrors session.list's kanban/tool deny-list; best-effort per-profile
    state.db probe degrades to null instead of failing the call)
  * profiles.create — ws twin of POST /api/profiles (clone_from/clone_all/
    no_skills/description), plus optional SOUL.md content and a best-effort
    model+provider pin; mirrors the CLI flow (seed skills, safe alias)
  Both run on the RPC pool, not the WS reader thread (list_profiles walks
  skill trees; create copies bundles).
- SDK: host.openSession(id, { profile, intent }) — open a stored session
  the way core surfaces do, soft-swapping to the owning profile's backend
  first (ensureGatewayProfile), and host.newChat(profile) — fresh draft in
  a named profile (same door as the sidebar's per-profile '+').
- Docs: desktop-plugin-sdk.md gains both surfaces.

First consumer: a Grok Bot-style 'Bots' roster plugin (one persistent
chat per agent profile with a New Agent dialog) built on exactly these
four doors.
2026-08-12 23:33:58 -07:00
Brooklyn Nicholson adbc77eb50 feat(desktop): setup_mcp tool — inline MCP consent card over the clarify-style blocking bridge
New desktop_ui tool: the agent proposes an MCP server (install/enable/
authorize + a one-line reason) and blocks on mcp.setup.request until the
renderer's consent card answers mcp.setup.respond with the outcome
(installed/enabled/authorized/declined/unanswered/error). Same lifecycle
as clarify: 10-min timeout, allow_expired late answers, tool lifecycle
events forced on so the card mounts even with tool progress off. Desktop
prompt hint steers the model to the tool instead of hand-editing config;
every other surface keeps the schema out and is pointed at hermes mcp
install.
2026-08-13 01:06:51 -05:00
brooklyn! b278dcb2d3 Merge pull request #83052 from NousResearch/bb/sidebar-all-profiles
Sidebar: show every profile at once
2026-08-10 03:57:47 -05:00
Brooklyn Nicholson 7e1f4f6f36 fix(desktop): ship the sidebar grouped by date in every scope
The all-profiles scope defaulted to grouping by profile, so "Reset to defaults"
handed back a grouping the user never picked. Both scopes now ship by date, and
a reset clears the scope you are not looking at too — otherwise flipping the
rail restored the customization the reset was supposed to undo.

Hovering a row's PR chip also holds the kebab back now: the chip is a link, and
the button that covers the end of the trailing slot was taking the click.
2026-08-10 03:50:34 -05:00
PRATHAMESH75 a1da384c6d fix(gateway): carry desktop_contract when activating a lazy session (#68392)
_live_session_payload() falls back to _fallback_session_info() while a
session's agent is still None (lazy/deferred build). That fallback omitted
desktop_contract, so session.activate returned lazy metadata with no contract
field. Desktop feeds the value straight into reportBackendContract(), where a
missing field reads as contract 0 — a current backend is then falsely flagged
"Backend out of date" on every activate of a live lazy session.

The sibling session.create shape (_lazy_resume_info) was fixed the same way in
#36112; this closes the remaining session.activate gap by advertising
DESKTOP_BACKEND_CONTRACT in the fallback payload.

Adds test_session_activate_lazy_info_reports_desktop_contract pinning the
session.activate path against a lazy (agent=None) session.
2026-08-10 01:34:24 -07:00
Teknium 7e1bfeab88 fix(desktop): read-only keyless plugin rows + backend contract v6
Rework of the salvaged #82828 compatibility layer: keep the crash guards
(optional key, safe filter/search, synthetic React row identity) but drop
the name-addressed toggle fallback — bare names collide across category
dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
moved to key-addressed toggles (a60b492e07). Keyless rows from a
pre-contract backend now render with a disabled switch and an 'update
your backend' tooltip instead of resurrecting the collision-prone
protocol.

Bump DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT to 6 so the
existing skew toast surfaces the real remedy (one-click backend update)
on session open.
2026-08-10 01:13:08 -07:00
Brooklyn Nicholson 5b68d2271b feat(profiles): serve a cross-profile project tree and per-profile usage totals
`projects.tree` answers for the backend's own profile, so the grouped
sidebar had nothing to draw once the user asked to see every profile.
Run the same authoritative builder once per profile against that
profile's state.db and merge the results by folder, so one checkout is
one group no matter how many profiles work in it, and the owning profile
rides on each session row where the badge and filter can read it.

Group totals are summed in SQL rather than over the loaded page — a
number that shrank as you scrolled would be worse than no number.

Scope the batched sidebar slices while we're here: cron and messaging
came back cross-profile unconditionally, which is why a concrete profile
showed another profile's Telegram threads and cronjobs.

Closes #65710
Closes #42651
Closes #70629
2026-08-10 03:13:08 -05:00
kshitij 327f7efab8 fix: close sibling display_kind drops and ui-tui parity for #82756
Review follow-ups on the composite salvage (whole-bug-class sweep):

- session.branch and _persist_branch_seed copied parent history without
  display_kind/display_metadata, so a tagged timeline marker (personality
  pivot, model switch, auto-continue) re-entered the branched session as a
  bare role=user row after a restart — re-planting the phantom-ordinal
  class this PR fixes. Both projection dicts now carry the tags; regression
  asserts added to both branch tests (mutation-checked: fail without the
  fix).
- ui-tui renderer learns display_kind=personality_switch (was falling
  through to an opaque user bubble; desktop got the case in commit 1).
- programmatic-integration docs: document the two new 4004 refusals
  (boolean ordinal, bare confirm_truncate).
- hermes_state comment: archived rows are searchable only with
  include_inactive=True, not by default search — align comment with the
  actual FTS filter.
- strip stray trailing blank line in test_tui_gateway_server.py
2026-08-10 11:01:15 +05:30
joaomarcos abd85a94bc fix(gateway): keep the personality pivot out of the truncate ordinal space (#82756)
`truncate_before_user_ordinal` is an index into the list of *real* user
turns. The gateway builds that list with `role == "user" and not
display_kind`, and `test_prompt_submit_truncate_ordinal_skips_display_kind_rows`
already pins why: "Without the filter, a trailing marker shifts the ordinal
so the wrong message is targeted for truncation."

`_apply_personality_to_session` broke that invariant at the producer. Its
pivot marker rides as `role=user` — deliberately, so strict
OpenAI-compatible providers accept it mid-conversation (the same reason
`_append_model_switch_marker` does) — but unlike the model-switch marker it
carried no `display_kind`. The gateway therefore counted it as a real user
turn while no client ever renders it as one.

After a personality change the two sides address different lists: every
later rewind/edit/regenerate resolves one slot too early, and
`replace_messages()` hard-DELETEs the extra span. That is the reported
signature — an in-range, valid ordinal, `confirm_truncate: true`, and a cut
that moved backwards with no user rewind action.

Tag the pivot like the model-switch marker, and teach the desktop to
project the kind as a timeline row so a persisted marker is never rendered
— or counted — as a user turn on the client side either. Both ends must
exclude it; excluding it on only one end just inverts the drift.

The regression test drives the real injection point rather than a
hand-written marker dict. Without the fix it fails with "the pivot shifted
the ordinal: the cut landed at 3 instead of 5", losing a turn the user
never asked to drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 11:01:15 +05:30
Gille 1439a65829 fix(tui): recover active goals after compression exhaustion 2026-08-10 09:58:46 +05:30
ethernet c002b6fbe5 fix(desktop): send full tool args so expanded rows show the whole command
The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.

Two paths had this fault:

- tool.start: the payload had no args until tool.complete, so the
  expanded row was truncated while the tool ran. Now tool.start ships
  the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
  discarded them. Hydration from this projection (watch windows,
  compress, branch, seeded create) kept only the preview, so the
  truncation was permanent. Now tool rows carry the args. This
  projection is the display view of the transcript — each renderer
  decides what to paint, and the preview stays for collapsed titles.

The DB rows do not change: the args already persist in tool_calls.
2026-08-09 17:48:37 -04:00
Teknium e95e13783b fix(docker): per-session container isolation and session-scoped workspace mounts
Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):

1. A NEW chat's container inherited the PREVIOUS session's workspace,
   bind-mounted rw at /workspace, because the mount source was the
   process-global TERMINAL_CWD env var (written by the workspace picker,
   outliving its session) and all sessions shared one 'default' container.

2. Every command failed with exit 126 because the desktop gateway recorded
   the HOST launch directory as the session cwd, and each command was
   prefixed with 'cd /Users/<user>/...' inside the container.

Fixes (class-wide, single owners):

- container_persistent: false + docker now keys containers PER SESSION:
  fresh container per chat, removed at session close/idle. delegate_task
  children share the parent's container via an explicit alias registry.
  container_persistent: true keeps the documented ONE-long-lived-container
  contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
  policy across all four env-creation sites; under isolation it refuses
  process-global cwd sources and mounts only the session's own attached
  workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
  sites already had (#50636/#54447 sibling site): a recorded host cwd is
  discarded on container backends instead of cd-ing every command into a
  nonexistent path.

E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.
2026-08-09 14:35:02 -07:00
Phil Thomas e7d01dd021 fix(personality): preserve config comments in TUI/gateway config writes
tui_gateway/server.py:_save_cfg called yaml.safe_dump on a deep-loaded
config dict, which reordered top-level keys alphabetically, stripped
every user-edited comment, and re-escaped non-ASCII (kaomoji/Chinese)
personality prompts to \uXXXX. Every TUI setting change - /personality,
/reasoning, /details_mode, /skin, /prompt - rewrote the file top to
bottom.

Changes:

* Add atomic_roundtrip_yaml_save(path, new_state) in utils.py - a
  comment-, ordering-, and unicode-preserving full-state replacement
  for yaml.safe_dump(cfg, f). Uses ruamel round-trip mode like the
  existing atomic_roundtrip_yaml_update, but accepts the whole cfg
  dict so callers that mutate multiple keys before saving (the
  _save_cfg pattern) don't have to be rewritten. Recurses into nested
  dicts, deletes keys missing from new_state (preserves the
  cfg.pop()-then-save semantic), and overwrites lists/scalars
  wholesale.

* Fail closed on an unreadable existing config.yaml the same way
  hermes_cli.config.atomic_config_write does, via a lazy import of
  require_readable_config_before_write (avoids a module-level circular
  import, since hermes_cli.config itself imports from utils). Also
  preserves both file mode and owner across the write, matching the
  existing atomic_roundtrip_yaml_update contract.

* Force-quote any new string value that YAML 1.1 would misparse as a
  bool/null (yes/no/on/off/true/false/null/~). ruamel's round-trip
  dumper resolves against the YAML 1.2 core schema and emits these
  unquoted, but PyYAML-based readers elsewhere in the codebase parse
  under YAML 1.1 rules - so an unquoted `approvals.mode: off` would
  silently round-trip back as the boolean False.

* tui_gateway/server.py:_save_cfg now delegates to
  atomic_roundtrip_yaml_save. Drop-in - all call sites (/personality,
  /reasoning, /details_mode, /prompt, etc.) inherit comment
  preservation and the fail-closed contract.

Tests:

* tests/test_utils_atomic_roundtrip_yaml_save.py - unit tests covering
  create-from-empty, top-level key-order preservation, comment
  preservation, readable Unicode, append-new-keys, delete-missing-keys,
  scalar/list overwrite, nested-dict recursion, refusal on an
  unreadable existing config, and owner preservation.

* tests/test_atomic_replace_symlinks.py - owner-preservation regression
  test mirroring the existing atomic_roundtrip_yaml_update coverage.

* tests/test_tui_gateway_server.py - 4 new tests pinning _save_cfg
  comment preservation, top-level key-order preservation, and
  unicode-readability under unrelated writes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 13:43:14 -07:00
Teknium 244d296646 fix(personality): single-owner personality state + one-time reset migration
Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.

- hermes_cli/personality.py: new single owner of personality state.
  Built-in personality definitions, neutral-name normalization, rendering,
  availability (built-ins overlaid by agent.personalities), overlay
  resolution, and the ONLY sanctioned persistence path
  (persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
  (announcing which personality was cleared and how to re-enable), plus a
  scrub of agent.system_prompt when it verbatim-equals a known personality
  render (machine-written by the old CLI/gateway). Hand-written manual
  prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
  marker in the list), gateway /personality, TUI config.set + slash path
  (which previously applied without persisting), TUI config.get (reports
  the EFFECTIVE personality), completer, hermes config display, and the
  tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
  desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
  available, one-time reset note.
2026-08-09 10:33:58 -07:00
Brooklyn Nicholson 368625e001 perf(gateway): warm every path the project tree will resolve
The warm covered session cwds, but build_tree also resolves each declared
project folder and each discovered repo root. Those were the last probes
running one directory at a time while the sidebar showed a skeleton.
2026-08-09 06:53:55 -05:00
Brooklyn Nicholson 3ef8cdd267 perf(gateway): quit reading system prompts the project tree discards
`_project_tree_row` keeps about eighteen fields and drops the rest, but
the query behind it selected `s.*` plus the resolved system prompt — 37MB
of blob per build on my session history, read out of the B-tree and then
thrown away.
2026-08-09 06:53:55 -05:00
Brooklyn Nicholson 34577fcb03 fix(gateway): rename a Discord thread once, after the reply lands
Titling is two-stage — a slice of the user's own words lands inline, the
model's version replaces it a second later — and the platform rename lanes
fired on both. That is two rate-limited calls to reach one name, and
Discord allows two channel renames per ten minutes, so the throwaway could
be the one that survived. The callback now carries which stage it is, and
the lanes take the model's.

The relay lane also asked where the reply landed at title time, which is
before the model has answered: it polled the send-result cache for ten
seconds and read the timeout as "never auto-threaded", so any turn with
tool calls in it silently kept its raw thread name. Wait on the send
itself instead — the adapter already owns that cache, so it can say when a
reply arrives and, just as usefully, that one arrived carrying nothing.
2026-08-09 04:33:58 -05:00
Brooklyn Nicholson 41d435ff4d fix(desktop): stop /home showing as a second Home project
/home and /Users hold home directories; neither is a workspace. A session
whose cwd was one of them got promoted to its own auto project, so the
sidebar listed a lowercase "home" row beside the synthetic Home bucket.
Both POSIX spellings are excluded on every host — macOS ships an empty
/home autofs stub, and container/remote shells hand back Linux paths — as
are the filesystem root and the parent of $HOME.
2026-08-09 02:28:02 -05:00
cryptoyasenka f2d03c1f2a fix(state,cli,tui-gateway): keep reasoning fields intact across forks and branches
get_messages() only deserializes content and tool_calls; the structured
reasoning columns (reasoning_details, codex_reasoning_items,
codex_message_items) come back as the raw TEXT they were stored as.
Feeding those rows straight back into a write, which is exactly what
the POST /api/sessions/{id}/fork handler does by piping get_messages()
into replace_messages(), hit an unguarded json.dumps() and stored the
already-serialized string encoded a second time. On replay of the fork,
json.loads() then yields the inner string instead of a list, and every
consumer's isinstance(..., list) gate silently drops it: preserved
Anthropic thinking blocks, Codex encrypted-reasoning/message-item
replay, and OpenRouter multi-turn reasoning context are all lost after
a fork, with one more encoding layer added per fork.

The /branch copy loop had the same defect from the other side: it
forwarded reasoning but none of the structured columns, and both TUI
branch writers persisted role/content alone, dropping reasoning and
reasoning_content along with them.

Route the six dumps sites in append_message and _insert_message_rows
through a shared guard that keeps already-serialized strings as-is;
structured values from the live runtime are dumped exactly as before.
Forward the reasoning fields in all three branch writers, matching the
set gateway/slash_commands.py already forwards on its own /branch path.
2026-08-08 17:37:26 -07:00
Brooklyn Nicholson f726090d48 feat(sessions): name a session the moment it starts
Titling fired on the first response, so a session sat unnamed for the whole
opening turn - p50 151s, p90 1212s across real sessions, because a turn is
tool calls, not one round-trip. A turn that failed or was interrupted never
got a title at all. Four surfaces each carried their own copy of the call.

Move it into the shared turn prologue and split it in two: a deterministic
title derived from the user's opening message, written inline before the
model runs, then one small-model call that upgrades it. The response is
constrained to a JSON object so there is no preamble to strip, and control
wrappers are stripped rather than refused, so a slash command titles as
what the user asked for instead of the command itself.
2026-08-08 17:07:21 -05:00