Refactor entry-side deletion refusal to execute in-transaction via
`_write_guards_reject(conn, sid)` (#123583), per maintainer review:
- Underlying `delete_session` and `delete_sessions` now accept an opt-in
kwarg `exclude_active_write_guards=True` running inside `_do` write
transaction, eliminating the race condition where a turn acquires the lease
between check and delete.
- Raises `SessionActiveWriteGuardError` when refusing single delete, leaving
the row untouched; `delete_sessions` atomically skips active rows.
- Checks both active turn leases and compression locks via the existing
reclaim-aware `_write_guards_reject` helper.
- Covers all user-facing delete sinks:
* Web `DELETE /api/sessions/{id}` -> 409 Conflict
* Web `POST /api/sessions/bulk-delete` -> skips active rows
* Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage
parents of active conversations are not pruned
* API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn
* CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1
* CLI browse picker -> refuses active delete
* TUI Gateway `session.delete` -> 4023 error
- Conforms to rubric with 2 targeted invariant tests in
`tests/hermes_state/test_delete_session_write_guards.py`.
- Updates user guide and web dashboard docs for 409 / exit 1.
(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
Let the shared status field builder accept an explicit owning home and have the
multiplexed TUI/Desktop session.status path pass its session profile_home.
Unscoped CLI/gateway callers keep the historical process-home fallback.
Add focused coverage for a secondary-profile session and for the launch-profile
fallback.
Fixes#124500.
session.interrupt silenced streaming TTS and left a voice-owned wake
pause in place, so the next wake phrase was ignored. Re-arm the caller
or voice-capture lease after an accepted interrupt, and after an error
that already cut TTS. A hosted-task not_interrupted mismatch leaves the
pause alone.
Fixes#117747
Round-4 pre-arm review warnings (ssh-only gaps, no local/docker change):
- A named ssh profile's cwd chain falls back to "~" before the launch
profile's host cwd, so a fresh or resumed TUI/dashboard session never
runs the remote shell in a host path.
- An ssh launch profile keeps a "~"/"~/x" cwd remote in session.create /
_completion_cwd (before main's host fast path), matching session.cwd.set
and workspace.move.
- One _is_remote_cwd_shape (reusing _is_ssh_remote_tilde_cwd) gates every
remote path: session.create no longer marks a relative remote path
explicit, and _completion_cwd returns the profile's own cwd (or ~) for
one, matching _workspace_cwd's rejection.
Live A/B over 6 launch modes x 7 profiles x 7 cwd shapes: every local,
docker and backendless cell is identical to origin/main.
Round-2 pre-arm gate findings:
- A named ssh profile's cwd is checked before any host expansion:
_profile_workspace_cwd tries the declared remote cwd first, and
_completion_cwd returns an ssh-bound path raw before expanduser/isdir.
A `terminal.cwd: ~` (or ~/x) no longer resolves to THIS host's home
and gets pinned as the remote workspace.
- _terminal_task_cwd_with_source switches to ssh only for a named ssh
profile; other named backends keep main's process-backend branch, so a
named docker profile under a local launch keeps its "session" cwd
source (docker isolation mounts from it).
- Launch-profile heal/reconcile keeps main's env check and only adds the
config-says-ssh case, so a docker-in-config launch still heals dead
host worktrees as on main.
- _completion_cwd does not fail an explicit client cwd for a deleted
profile (main never resolved the profile on that path).
- One _workspace_cwd(profile_home, raw) validates a picked workspace for
both _set_session_cwd and session.workspace.move.
- The profile-policy helpers live beside their callers in
session_workdir; reuse hermes_cli.config._is_ssh_remote_tilde_cwd.
- session.create resolves the backend only when a cwd was sent.
- Tests write a real profile config.yaml instead of stubbing the backend
helper; pin the "~" case.
Pre-arm gate findings on the salvage stack:
- The "cwd lives on another host" exemption is ssh-only (_cwd_is_remote).
Docker and the other backends mount or copy HOST paths: _set_session_cwd
keeps the host isdir check and always calls cleanup_vm, so a docker
session moving workspaces gets a fresh container with the new mount
again (the non-local early return skipped it).
- _ensure_session_db_row no longer force-writes the row cwd. It runs on
every prompt, and each update_session_cwd bumps git_metadata_generation,
which made an in-flight git-meta probe fail to publish. The eager row at
session.create already lands the cwd before the agent's INSERT-OR-IGNORE.
- A named profile's backend and remote cwd come from the policy its turns
actually run under (tools/terminal_scope.build_profile_terminal_scope:
defaults <- .env <- config.yaml), so a .env-only TERMINAL_ENV=ssh
counts; _profile_configured_cwd is back to main's body.
- An ssh profile's own terminal.cwd is also used when the client sends no
cwd (_profile_workspace_cwd, shared with resume).
- session.workspace.move takes the backend from the live session's
profile (the same one _set_session_cwd uses) and validates once.
- _hydrate_session_cwd resolves the backend outside _sessions_lock.
- _completion_cwd keeps main's host fast path; the backend is only
resolved when the path is not a host dir.
- Delete the now-dead _is_local_terminal_backend; the cwd-follow fixture
patches _effective_terminal_backend instead (its old patch was a no-op).
- Trim three tests that re-asserted main's local behaviour or duplicated
the real-config tests.
Follow-up to the #105749 + #123903 salvage:
- A named profile without terminal.backend is local. Both contributor
helpers fell back to the LAUNCH profile's backend, so a local profile
opened from an ssh launch had its terminal.cwd treated as remote.
- _bound_terminal_backend() is the single resolver (create, completion,
workspace.move, display heal, settle-follow, terminal tool). The
terminal tool now reads it too, so an ssh profile under a local launch
keeps /home/kali instead of the display heal persisting /home.
- _declared_remote_profile_cwd() only honours a profile that itself
declares backend: ssh; the placeholder set is gone (the ~/absolute
shape check already rejects ".", "auto", "cwd").
- One loader for a named profile's terminal section
(_profile_terminal_cfg), shared with _profile_configured_cwd.
- Eager row at session.create and "row cwd = explicit" on hydrate apply
to remote sessions only; local project drafts stay lazy and keep
settle-following, as on main.
- config.get project forwards the pinned profile (and marks a picked
path explicit) so the desktop gets the remote dir back; the renderer
keeps adopting the server's normalized cwd for local users (WSL
translation, abspath) instead of bypassing it.
- Dropped #123903's cwd_explicit-decides-intent change in session.create:
it made every local desktop new chat in a project lose its workspace
and AGENTS.md.
The app showed the profile's terminal.cwd, but SSH sessions still ran in the
launch profile's directory because a remote path that does not exist on the
desktop host was discarded.
(cherry picked from commit e4f4a43ebc4408adb6ac37e8de1ee7ff414a9158)
A multiplexed gateway serves many profiles from one process; at session.create
HERMES_HOME is not yet rebound to the target profile, so the process-global
backend check reads the launch profile (usually local) for a session bound to an
ssh/docker profile. The local isdir gate then drops the session's remote project
cwd, and the sidebar/terminal fall back to Home / the profile's ~ dir. Read the
BOUND profile's terminal.backend and, when non-local, trust the remote path raw
across the whole cwd path:
- _completion_cwd / session.create explicit_cwd / _set_session_cwd / workspace-move
and a session-aware _session_is_local_backend (no launch-process backend reads);
- don't heal a live remote cwd down to /home (env-OR-config backend check);
- persist a project session's row eagerly with its cwd, and force the cwd on after
the AIAgent INSERT-OR-IGNORE, so the sidebar keeps it out of Home;
- mark a hydrated row cwd as explicit so the remote terminal uses it, not ~.
(cherry picked from commit 35511526c5633e28e275f231e1735c2394f6afec)
A correction delivered to the provider mid-compression aborts the
compression (explicit_interrupt) - the user's follow-up kills the very
turn that would have answered it. The channel-side busy path already
demotes interrupt->queue for this exact reason (gateway/run_busy.py,
#56391); mirror that demotion for the local RPC path so TUI, Desktop and
classic chat share the Discord-gateway contract: prompt.submit busy
steer/interrupt and session.steer/session.redirect queue instead, and
the follow-up drains when compression finishes.
Fixes#61042
The desktop composer's manual model pick rides into session.create as a
per-session override and silently wins over model.default for every new
chat. Name the override and the profile default in agent.log so the
choice is diagnosable from logs alone (#107410).
session.most_recent applied only the listing deny-list, so a
source='unknown' row minted by the update_token_counts guard (legacy
messages with no sessions row) could outrank the session the user
actually opened and get auto-resumed — the sidebar then highlighted one
chat while the pane streamed another (#54320).
Auto-resume now uses its own deny check: the listing deny-list plus
source='unknown'. Human-facing listings keep showing those rows (they
may be a real session awaiting repair) — only the pick-a-session-for-me
path skips them.
Supersedes https://github.com/NousResearch/hermes-agent/pull/54479 (whose
promotion/repair approach is now delivered on main by the
token-accounting guard's source-aware upsert; its remaining
most_recent deny is adopted here).
Fixes#54320
Co-authored-by: Dustin Persek <dustin.persek@protonmail.com>
Rebased onto main, where params are validated against tui_gateway/contracts
and unknown keys are rejected: the copy_parent_history / omit_messages flags
on session.create and session.branch now answered 4000. Keep both methods'
wire shape unchanged and give the two new methods their own contracts
(session.branch_stored, session.branch_whole) with messages_omitted results;
the handlers take the behaviour as keyword arguments, not wire flags.
With no live turn, AIAgent.steer() still accepted the text and the gateway
answered 'Steer queued'. Nothing drained it until the next turn's pre-API
drain spliced it after the newest tool row, possibly one from an earlier
turn (misplaced, cache-breaking), or it was requeued.
- command.dispatch /steer: idle sends the text as the next turn with a
notice (CLI parity) instead of calling agent.steer().
- session.steer: idle answers 'rejected' without touching the agent, so
clients fall back to their next-turn queue.
- TUI /steer: a rejected steer is queued for the next turn, not dropped.
Fixes#64578
Conflict resolutions and semantic fixups:
- tools/environments/base.py: main's hard-exit kill fence (kill a spawn the
fence missed, deregister from _live_foreground in a finally) wrapped around
pm-clean's output collector.
- pyproject.toml: pm-clean's marker list plus main's new `live` marker.
- hermes_cli/main.py: pm-clean runs startup recovery from hermes_bootstrap, so
the old early-recovery block stays gone; main's interrupted-pull restore
(auto-merged above it) runs right after bootstrap, as on main.
- hermes_cli/update_cmd.py: main's interrupted-pull marker now guards
pm-clean's first tree mutation (release-tag detach, ff-only, or reconcile)
and is cleared once git is done. The marker's target is the ref git actually
moves to (a release tag, not always origin/<branch>), since the restore
compares against it.
- hermes_cli/_early_recovery.py: restore `import subprocess`, which pm-clean
had dropped and main's auto-merged restore needs (NameError on the first
launch after a killed update; test_update_interrupted_pull red -> green).
- apps/desktop/src/i18n/{de,es,fr}.ts: main's new locales carry the full
settings.about block; trim it to `updates` as pm-clean's type and the other
overlays do (tsc: 27 errors -> 0).
- main's new e2e tests: `import yaml` -> hermes_yaml; wake-word import table
names pyopen_wakeword (pm-clean's wake-openwakeword extra); the anthropic
key-leak switch leg needs the SDK, and the api_server two-tenant test needs
aiohttp, both PM runtime extras the test env does not carry.
* fix(desktop): preserve interim commentary across history and tool boundaries
* fix(desktop): preserve interim commentary across history and tool boundaries
* fix(desktop): keep public Codex commentary out of hydrated thinking
Project profile-scoped, sanitized display commentary and reasoning for REST and gateway history without changing persisted replay items. Preserve canonical finals across stream recovery and keep tool-delimited interim text through settlement.
---------
Co-authored-by: Xipong <217837358+Xipong@users.noreply.github.com>
The branch copies the parent transcript byte-for-byte so its first turn can hit the warm
prefix cache, but the child row was created without a system prompt. The child's first
build then found nothing to restore and re-probed the workspace, rewriting the prompt at
byte 0 for any repo that moved since the parent's session start — the same rewrite this PR
removes for /compress and resume, on the one session transition it did not reach. It also
logged the "stored system prompt is null; investigate update_system_prompt" WARNING for
every branch.
Both branch writers now pass the parent's prompt into create_session: the CLI prefers the
running agent's cached bytes (what this process sends) and falls back to the parent row;
_persist_branch (TUI/Desktop, seeded and lazy) reads the parent row. With the row in place
the seeding in agent/system_prompt.py replays the snapshot on the branch's later rebuilds.
Tests: one per surface, red on the PR head (child["system_prompt"] is None), green here.
Conflict resolutions and semantic fixups:
- utils.py / hermes_yaml.py: main widened ruamel's round-trip emitter so a long
double-quoted scalar is never folded after an escaped backslash. pm-clean builds
every rt emitter through hermes_yaml.roundtrip_yaml(), so the width lives there
(ROUNDTRIP_YAML_WIDTH moves with it); xai_retirement imports it from hermes_yaml.
- hermes_cli/banner.py: keep pm-clean's removal of the banner update check. Main's
GIT_NO_LAZY_FETCH fix for it applies to its replacement, source_check: every
read-only probe (source_git_env) now refuses promisor lazy fetches, and the
partial-clone test targets that probe (red without the flag).
- .github/workflows/tests.yml: keep setup-pm; main's uv pin bump does not apply.
Main's WAL-capable SQLite gates are kept, run against $HERMES_PYTHON (the
PM-pinned interpreter, SQLite 3.53.1). The e2e step takes main's
--include-integration invocation.
- apps/desktop: package.json has no build block here, so main's macOS locale-marker
restore joins the darwin branch of the existing after-pack.mjs, and its test
loads the hook from electron-builder.config.cjs and imports PlatformPackager
from app-builder-lib's root (electron-builder 27 exports no ./out paths). The
win32 row is dropped: this hook sanitizes and signs PE trees on win32 by design.
- reconciliation.ts: main's rowId hydration (#119326) was merged into the first of
pm-clean's split helpers only; the resolver is now one helper both halves use.
- en.ts: both sides' keys kept. tests/tools/test_lazy_deps.py stays deleted.
- Tests main added with `import yaml` use hermes_yaml, like the rest of the tree.
One Desktop backend serves several profiles. Two session-bound paths read or
wrote the LAUNCH profile instead of the session's:
- model.save_key was not @_profile_scoped: a key saved from a secondary
session (session_id) or for an explicit profile landed in the launch
profile's .env, and the handler then exported it into the shared
os.environ. It now binds the profile scope like model.options, and the
explicit os.environ publish is gone (save_env_value already publishes to
the bound scope, and to os.environ only for the launch profile).
reconcile_record() already skips a non-launch home, so the profile-param
guard around it is dropped. model.disconnect had the same gap (it removed
the launch profile's credentials) and gets the same decorator.
- session.create's info.model and the first state.db row resolved the
default model from the launch profile's config. _session_default_model()
resolves it under the session's own profile scope; the same launch-model
fallback in the lazy resume info, the fallback session info, the live
session identity and the branch row now use it too.
Found by the two-tenant Desktop backend canary
(tests/e2e/core/tenancy/test_two_tenant_desktop_backend.py): alpha's saved
key appeared in default's .env, and alpha's session.create reported
default's model.
Reloading a Desktop session that was still live on the backend took the reattach path,
whose `info` said `model: _resolve_model()` (the config default) and carried no provider.
Desktop writes info.model/provider straight into the session state the picker renders, so a
chat pinned to a plugin provider in the composer flipped to the profile default on reload,
and flipped back once the backend had dropped the session and the eager resume restored the
stored override. Reported as "the model changes randomly when reloading" with the Claude
subscription plugin.
`_live_session_identity(session)` answers with the precedence `_session_info` already uses
for the eager path (queued switch, metadata mirror, built agent, the deferred record's
composer override) and falls back to the profile default only when the chat never made a
pick. The reattach `info` now carries `provider` too, like every other resume shape.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
Replace the direct `_stored_session_runtime_overrides` call test with one that drives the
production path: `_apply_model_switch` on a follow_profile_config row under profile B writes
the composer_override_profile marker into B's real state.db row (`_persist_live_session_runtime`),
and `session.resume` on the deferred path restores the pin while B's config.yaml model is
unchanged and drops it once the profile model moves. Launch home A carries a different model
throughout, so the compare is proven to run under the session profile's scope (A->B->A).
The test is red when the persist hunk in server.py or the record/_resume_eager wiring in
methods_session.py is reverted; the first-write projection in session_workdir.py is pinned by
the extended `_ensure_session_db_row` marker test (a pick before the first send).
One `_row_follows_profile(row)` helper replaces the three copies of
`follow_profile_config or title == "Bot Chat"`; identity is the persisted marker, the raw
title compare stays only inside the helper as the legacy-row fallback.
Docs: one sentence in the Bot Mode guide on the composer pick sticking to the chat until
the Bot's profile model changes.
Conflicts resolved toward the PM model: main's lazy_deps/update_cmd_deps/npm
stamp machinery stays deleted (PM + scripts/build/node-deps.mjs own it), the
systemd ExecStop stop-mark rides the installation launcher, legacy
linux_only/macos_only/windows_only markers are rewritten to platforms(), and
finalize_update_receipt carries pending manual-serve obligations forward
again (lost when the ContextVar receipt rewrite crossed c0aa3ce354).
Test harness: the real-home I/O guard exempts /proc/<pid>/fd metadata reads
(deleted-WAL holder scans) and run_tests.sh drops ~/.hermes PATH entries so
shutil.which() cannot trip the tripwire.
_resume_deferred (Desktop defer_history) and _resume_cold called
_stored_session_runtime_overrides outside _profile_build_scope(ctx.profile_home), so a
secondary profile's stored custom:<name> provider was judged against the LAUNCH profile's
config, "healed" to the launch entry for the same endpoint, and the build inside the
secondary profile died with "Unknown provider". Both paths now bind the session profile
scope like _resume_eager already did.
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
A composer, script or older client can pin a model override the selected
provider does not serve (gpt-5.5 on anthropic; the incident pair
deepseek/deepseek-v4-flash-0731 on openai-codex). The gateway minted the
session anyway and the FIRST turn died with the provider's 404, leaving a
dead chat the user had to diagnose and recreate.
session.create now checks the pair before any session state exists and
answers JSON-RPC -32602 naming the model, the provider and up to five
closest models from that provider's curated catalog (error.data carries
them structured). The check is offline and refuses only what Hermes knows
belongs elsewhere: a foreign-family name another native vendor's catalog
lists, or any foreign-family name on the strict OAuth catalogs
(openai-codex / xai-oauth). Custom endpoints, aggregators, same-family
names the curated list lacks and names no catalog lists stay permissive.
Without an explicit provider the pair is judged against the provider the
session would build with (profile config, then env) under the profile's
scope.
Direction and reject-before-side-effects shape from #96845 by
@victorftrdba, trimmed to the offline catalog rule.
Fixes#96817
The CLI/TUI slash worker and gateway /usage now render Codex quota windows, but the
Desktop usage feed reads the `session.usage` RPC, which returned only Nous
`credits_lines`. Add `account_lines` (the same `render_account_usage_lines` block,
fetched against the session's live route or the configured `model.provider` when no
agent is built) and render it in the Desktop `renderRpcResult` ahead of credits.
Fail-open like the credits block.
Desktop sessions landed in state.db with an empty user_id even after a
password login: the backend resolved the identity at WS-upgrade auth (it
writes login_success with the right user_id to logs/dashboard-auth.log) and
stamped it on the session record as auth_user_id, but the row-creating write
never passed it on — and user_id is only ever set at insert, so no later,
identity-aware writer could fill it.
_ensure_session_db_row and _persist_branch (branch children) now stamp the
same <provider>:<id> identity the agent is built with. Anonymous records
carry no login, so those rows keep their empty user_id exactly as before.
session.set_hidden is two-tier by design: live runtime id first, then a stored id/key
resolved in the profile db (the Bot Mode sweep and any plugin reconciling sessions it
owns hold stored ids for chats that are not live). The first tier went through
_sess_nowait, which logs "session-scoped RPC rejected: … not in memory (detached/reaped
runtime; client should resume the stored session)" — a warning meant to make a vanished
prompt.submit diagnosable — for every hide that was then fulfilled from the db. A
startup sweep over a handful of stored ids thus wrote a burst of false "rejected" lines
and buried the real stale-runtime-id signal (#114694).
Look the live session up quietly; an id neither live nor stored still returns 4001.
Same class as the Bot Chat drain wedge already on this branch: every JSON-file
scan guarded "did it parse?" and then assumed the value was a dict. A file
holding `42`, `"oops"` or `[1,2,3]` (corruption, truncated write, foreign tool)
passed the guard and raised AttributeError/TypeError at the first `.get()`,
usually before a single healthy sibling was processed. Each site now treats a
non-object payload like a corrupt file under that subsystem's existing policy:
- tools/bot_relay.py::_expire_if_stale / claim_pending_envelopes — the
envelope is skipped by the sweep and not claimed (same as unparseable).
- tools/browser_lightpanda.py::reap_orphaned_lightpanda — record unlinked,
scan continues.
- tools/write_approval.py::list_pending / get_pending — record skipped with
the existing "unreadable pending record" warning / None.
- tui_gateway/methods_session.py::_legacy_spawn_tree_entry / spawn_tree.load —
scalar snapshot reads as empty / returns the existing 5000 error instead of
violating the SpawnTreeLoadResult contract.
- hermes_cli/local_runtime/binaries.py::manifest_verified — False.
- plugins/platforms/a2a/protocol.py::load_conversation — non-dict lines are
dropped, keeping the declared list[dict] return.
- batch_runner.py::_load_dataset / _scan_completed_prompts_by_content /
_combine_batch_files — line skipped and counted as filtered.
- trajectory_compressor.py::process_entry_async — scalar entry passed through
unchanged.
Ported from the source hunks of PR #114241; its gateway/shutdown_flush.py
drain_transcript_spool hunk is left to open PR #84785, and its
recover_pending_to_db / cron / bot_live_delivery / bot_mode_dm hunks are
already on this branch or on main.
(cherry picked from commit d4b54568887e69b3ee3d363ebe4dcd657ccf64f9)
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
`hermes chat -q`/`--oneshot`/`-Q` and `hermes -z` (both set HERMES_SINGLE_QUERY_SESSION=1)
persisted their session as `cli` — and, before the first pass, as the inherited
`tui`/`desktop` transport label — so finite automation runs sat in the TUI, Desktop and
dashboard session pickers next to real conversations (#112550).
- run_agent._session_source_for_agent: a single-query run whose source is empty (or an
inherited UI transport label without an explicit --source) resolves to `oneshot`; the
platform gate keeps delegate children (`subagent`) untouched; an explicit `--source`
(HERMES_SESSION_SOURCE_EXPLICIT=1 from main.py) still wins.
- hermes_state_sessions.INTERNAL_LISTING_SOURCES = (kanban, tool, oneshot) replaces the
three copied `["kanban", "tool"]` literals (tui_gateway session.list, console
`sessions list`/`stats`, in-chat /sessions), and the Desktop project tree / sidebar
recents and the dashboard automation set exclude `oneshot` too.
- `hermes -c` / `--resume latest` still chain on the previous one-shot (PR #105957's
documented flow): the CLI MRU lookup matches the cli family {cli, oneshot} and
search_sessions accepts several sources; one-shots keep stamping their launch cwd so the
workspace-scoped lookup keeps working.
- Compression child: the rotated child is published with the PARENT ROW's persisted source
instead of bare agent.platform, so a `--source tool` / `oneshot` / inherited `kanban`
session does not degrade to a picker-visible `cli` row after compaction.
- Docs: sessions source table (+ oneshot/kanban/tool rows, compression note) and the
`--source` flag reference (explicit flag always stored as given).
Once a `serve` backend hosts a second profile home, get_secret() fails closed for any
body with no secret scope installed. Two off-turn paths rebuilt the system prompt with
only HERMES_HOME bound (or nothing at all), so the external memory provider's
system_prompt_block() -> get_secret("OPENVIKING_API_KEY") raised UnscopedSecretError
and the launch profile lost its memory block:
- `session.context_breakdown` (tui_gateway/methods_session.py): the Desktop status bar
refetches it after every turn, which is the once-per-turn warning in #112927; it also
resolved a secondary session's provider credential and home from the launch profile.
- `_persist_live_session_system_prompt` (tui_gateway/server.py): model switch / one-turn
restore re-persist. Bound HERMES_HOME alone (#50233); now the full runtime scope.
Both now enter `_session_profile_runtime_scope(session)` (home + secrets + terminal
policy, the launch profile's frozen scope when profile_home is None), the same binding
the turn itself uses. config.show is scoped in the salvaged commits before this one.
Regression (red on base): tests/tui_gateway/test_multi_profile_hosting_fail_closed.py::
test_off_turn_prompt_rebuilds_run_under_the_sessions_profile_scope — A->B->A over two
homes, each rebuild sees its own home and MEM_PROVIDER_KEY, os.environ untouched.
Refs #112927
The desktop's contract-skew guard reads a missing desktop_contract the same as an old one ((contract ?? 0) < REQUIRED), so any session-info shape that omits the field pops a false "Backend out of date" toast. #36112 fixed the lazy session.create path; three builders still omitted it: _cwd_info (cwd change), _resume_live_unpersisted (resume of a live lazy session with no state.db row), and the workspace-move session.info in agent_callbacks. All client runtime-info paths now carry the field.
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
`hermes serve` / the Desktop backend hosted many profile homes (session
profile_home, the `profile` RPC param, hosted rooms) but never called
agent.secret_scope.set_multiplex_active, so every unscoped get_secret read for
a secondary silently returned the LAUNCH profile's os.environ value, and
@_profile_scoped bound only HERMES_HOME: `config.get full` for profile B
expanded B's `${VAR}` refs to the default profile's plaintext credentials,
model.options listed the default's env-keyed providers, llm.oneshot billed the
default's auxiliary key.
- tui_gateway/launch_profile_policy.py (was launch_terminal_policy.py): the
first time _profile_home registers a non-launch home the process freezes
the launch env and flips get_secret to fail closed
(activate_multi_profile_hosting); launch_secret_scope composes the launch
profile's .env + external sources over that frozen env so systemd / op-run
injection survives the flip while a secondary never sees it.
- model_switch._profile_runtime_scope_tokens is the ONE composer for
home + secret + terminal scope: a named profile binds its own files; the
launch profile binds its frozen-env scope once multiplexing is active and
stays unscoped in a single-profile process (legacy os.environ precedence).
_profile_scoped, _profile_scoped_rpc, _session_profile_runtime_scope,
_bind_build_profile_scopes and _prepare_turn_input all go through it.
- Hosted-room / Group Chat turns for a DEFAULT-profile member in a
`multiplex_profiles: true` gateway no longer die at agent build with
UnscopedSecretError: `profile_home is None` was treated as "no scope"
in _start_agent_build._build and _prepare_turn_input.
- llm.oneshot runs under the session's (or params.profile's) scope;
_lap_builtin_rows / _overlay_has_creds / _provider_has_credentials read
provider keys through _scoped_key_env instead of raw os.environ;
methods_groups._profile_execution_policy resolves the hosted-room policy
(which reads provider credentials) under the profile's full scope.
Live repro (real `hermes serve`, two homes, config.get {key: full, profile: b}):
base a_ref: <A_VALUE> b_ref: ${B_ONLY_TOKEN} env_ref: <ENV_INJECTED>
head a_ref: ${A_ONLY_TOKEN} b_ref: <B_VALUE> env_ref: ${ENV_INJECTED_TOKEN}
Control (one home, --single): launch config still resolves env_ref from os.environ.