Desktop archives sessions via PATCH /api/sessions/{id} -> set_session_archived,
but the TUI gateway had no equivalent JSON-RPC method, so TUI clients had to
keep every session visible or delete it permanently. Open PR #47184 added this
against the pre-split server.py layout and resolves only live runtime sessions;
this lands the method on current main next to session.set_hidden with the same
two-tier resolution: live runtime id first (unpersisted drafts defer via
pending_archived, applied by _ensure_session_db_row like pending_hidden), then
a stored id/key resolved through the profile db, covering the historical rows
the session.list picker shows. Contract declared in tui_gateway/contracts and
rendered into the generated TS/OpenRPC catalog.
Fixes https://github.com/NousResearch/hermes-agent/issues/47168
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-3 pre-arm gate findings:
- _completion_cwd falls back to the raw path for an ssh LAUNCH profile
after main's host fast path, so session.create with a remote-only
explicit cwd no longer marks the gateway's own os.getcwd() as the ssh
session's workspace (and eagerly persists it). workspace.move and
_set_session_cwd already treated launch ssh as remote.
- A deleted profile only fails _completion_cwd where main consulted it
(non-explicit client cwd, or no client/session cwd); one resolve.
- A named ssh profile with no terminal.cwd gets "~", never the launch
profile's TERMINAL_CWD / terminal.cwd host path.
- Heal/reconcile: only a named ssh profile is special-cased; every other
session keeps main's env check, so named docker/local sessions heal
exactly as on main.
- _workspace_cwd rejects a relative remote path (it was stored and
git-probed relative to the gateway's cwd).
- Reconcile checks the free explicit-cwd early return before resolving
the backend.
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 new chat bound to a named profile carried the desktop's app-global
workspace cwd (the launch profile's configured directory or the current
project scope) unconditionally, so a profile with its own terminal.cwd
was silently overridden on both create and resume.
The desktop now ships cwd_explicit provenance with the create RPC — true
only for a deliberate workspace pick (folder picker, an explicit lane or
tile target), false for the inherited default — and the gateway lets a
named profile's configured terminal.cwd win over a non-explicit client
cwd. An explicit pick still wins; a profile without a configured cwd
keeps the inherited workspace.
Fixes#52589
Co-authored-by: Sahil-SS9 <Sahil-SS9@users.noreply.github.com>
A partial drain followed by a crash left a later-accepted prompt's
accept-time row behind the in-flight reply while the dispatched prompt's
row healed past it — permanently rendering the later prompt before the
earlier one. The drain now re-places every queued envelope's row in
acceptance order and re-slots the dispatching envelope's durable dict for
adoption (the loop would otherwise leave the last processed one in the
single slot). Regression test covers the two-image partial-drain crash;
behavioral RED first ("a later-accepted prompt rendered before an earlier
one", assert 34 < 10).
(cherry picked from commit 09db3b9d84fbd0ae6961732208e7899fca380df7)
A prompt accepted while the agent was busy lived only in the in-memory
queue: session.resume's cold read lacked it until its turn ran and a
backend restart lost it permanently. _handle_busy_submit now writes the
user row through the same #111868 machinery as an idle submit (extracted
as _write_submit_user_row; the durable dict rides the QUEUE ENVELOPE,
never the shared session slot the in-flight turn may own), a text-only
merge syncs the already-written row's content in place, and
_drain_queued_prompt re-places the row at the transcript end before
dispatch (fresh write + deactivate_message on the early row) so the
stored raw order stays [u, a, u, a] instead of glueing the two user
turns under repair_alternation, and the drained turn adopts the fresh
row instead of appending a duplicate. Cancel/crash keep the trailing
user row (the documented interrupted shape). display_kind rides the
envelope from prompt.submit through both writes.
(cherry picked from commit 32792f99d582608d68d40bc12930b295af0d332e)
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.
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.
The row prompt.submit writes at send time was staged on the session and
only dropped by _clear_inflight_turn. A turn that ended without reaching
the agent (deferred build failed or the bounded wait expired, ownership
refusal, agent missing) left the staging dict behind, and the next turn
that reached _invoke_agent WITHOUT a prompt.submit (wake-up, auto-continue,
queued drain) popped it, saw its content differ from its own prompt, and
rewrote the user's durable row to the synthesized text before adopting it
as its own input — the user's message vanished from the transcript.
- _adopt_submit_user_row now takes this turn's raw submit text and adopts
the staged row only when its content matches; anything else is discarded
without touching the DB.
- _fail_inflight_turn and both _admit_prompt_turn refusals drop the staged
row, so every path that ends a turn before _invoke_agent releases it.
Desktop: migrateTranscriptTailsForProfile re-keys local-connection tails
only (a same-named profile on a remote connection was not renamed), matching
migrateTilesForProfile; the "Wipe the whole cache" docblock is back above
clearTranscriptTails.
Part of #111868
Two data-loss paths around Desktop sessions (#111868).
A. prompt.submit wrote the session row at send but the user's message only once
the agent finished building, so quitting a frozen app during a slow first build
left an empty session with no message. The message is now appended right after
the row (_persist_submit_user_row) and staged on the session already stamped
durable; the turn hands it to the agent as _pending_cli_user_message, which
_stage_turn_user_message adopts by identity so the crash persist and the
turn-end flush write no second row. A prompt the prologue rewrote (@-expansion,
image parts) updates that row first (SessionDB.set_user_message_content) so the
durable transcript replays what was sent and the api_content sidecar can address
it. A turn cancelled before the agent was ready drops the staged dict with the
inflight turn so a later turn cannot adopt it.
B. A profile rename left tabs, Bot tile owner routes, cached transcript tails,
the remembered session/route and session owner hints keyed by the old profile
name, so every restored tab dialed a backend that no longer existed and looped on
"Couldn't open this session". migrateTilesForProfile(old, new) — the rename
sibling of dropTilesForProfile — moves every family to the new name; the rename
dialog calls it once the backend rename succeeded (local, non-default).
Two rewind tests asserted the durable transcript between submit and turn; they
now include the prompt just sent, which is the new guarantee.
Workspace moves stamp agent.session_cwd so a lazily started Codex thread
starts in the moved-to directory. Agents that never had the attribute
(test doubles, slotted objects) must keep working, so stamp only when the
attribute exists. Test stubs of _set_session_context mirror the new cwd
kwarg, and the Codex gateway test asserts the contract (no pinned session
cwd) instead of the attribute's absence.
On origin/main only the TUI copied durable `_row_id`s onto the installed warm
prefix (its clients address follow-ups by row); folding the loop into
`rewind_user_turn` made CLI /undo grow `_row_id` on a resumed history that never
had it. Live CLI rows already carry ids from the flush, so for them it was a
no-op, but a resumed transcript changed shape. The loop now runs only with
`adopt_row_ids=True`, which the TUI passes; the CLI history shape is unchanged.
Review follow-up on #109610.
CLI `_rewind_persisted_user_turn`, TUI `_rewind_active_session_history` and gateway
`rewind_session` each re-ran get_active_message_ids -> get_messages_as_conversation ->
split_user_originated_turn -> rewind_to_message with their own warm/durable comparison
helpers and three different out-of-range contracts (RuntimeError / ValueError / None).
The durable transcript is the authority for a rewind, so the implementation now lives
with the data: `SessionDB.rewind_user_turn` (hermes_state_rewind.py) with one typed
out-of-range error (`RewindTargetUnavailableError`). Surfaces keep only lock, eviction
and rendering glue and map that error to their own message.
* fix(tui-gateway): a seeded session is durable at create, and its seed is written once
session.create accepts opening messages. Three defects sat in that path:
- A seeded session without a parent was never persisted at create, so a
restart before the first prompt lost it and session.resume answered
4007. Only branch children (#93959) were persisted up front. The
same rationale applies to any seeded create: seeded content is
intent, not an abandoned draft. Parentless seeds now persist their
row, transcript and client title at create; empty drafts stay lazy.
- _coerce_seed_history dropped display_kind, so a seeded row tagged
"hidden" (model-facing scaffolding) rendered as a user bubble. The
coercion keeps "hidden" and only "hidden"; every other kind is
stamped by the gateway at turn time and is not accepted from the wire.
- A branch child's seed was written twice: _seed_branch_row copied it at
create but never marked it persisted, so the first prompt's
_persist_branch_seed appended the copy again. The create path now
sets _branch_seed_persisted, and the gate is a create-time `seeded`
stamp instead of parent_session_id, so a resumed session (whose
history comes from the DB) can never re-append its transcript.
Two invariant tests, both red on main: a parentless seed survives a
gateway restart with the hidden row kept out of the wire transcript and
not re-written by the first-submit path; a branch child's seed is stored
exactly once. The reasoning-fields fixture stamps `seeded`, the flag
session.create sets.
* fix(tui-gateway): a hidden seed row stays out of the list preview and the create count
Live-testing the seeded create on every surface showed two places where
the newly durable hidden row (display_kind="hidden") still surfaced:
- session.list built a session's preview from its first user row with no
display_kind filter, so a hidden opening row (model-facing scaffolding
the gateway never paints) became the sidebar preview. The preview
predicate now skips hidden rows, in every listing query that shares it.
- session.create reported message_count as the raw seed length while its
messages array already filtered the hidden row (2 vs 1). It now counts
what is on the wire, the same rule session.resume applies.
Both are covered by the existing seeded-create test: the create count
equals the wire transcript, and the preview of a session whose first
user row is hidden is its first visible user row.
* fix(tui-gateway): a live unpersisted resume counts the wire transcript
session.resume on a live session that has no row yet reported message_count as
the raw history length while its messages array was already filtered, the same
mismatch the previous commit fixed on session.create. Count the wire, as the
cold, deferred and reuse-live resume paths already do.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
No runtime consumer read the proxy (terminal_tool/environments call is_interrupted()/set_interrupt()
directly); its only users were tests patching tools.interrupt._interrupt_event, which had no effect on
the code under test. tools/terminal_tool.py's own re-export of the name is owned by another worker.
hermes_state.py: delete every '# noqa: F401 (re-exported...)' import block (hermes_state_common/errors/guard/
readpool/sessions/fts/dbfile/wal/repair/registry + agent.context_compressor _DB_PERSISTED_MARKER_KEY); keep
only the names hermes_state.py itself uses, without noqa.
hermes_state_registry.py: drop get_shared_session_db/release_shared_session_db/close_shared_session_dbs
aliases; every caller (gateway/, tools/, tui_gateway/, cron/, mcp_serve, run_agent, tests) now imports
acquire/release/close_all/release_or_close from hermes_state_registry.
hermes_state_titles.py: drop set_auto_title_if_empty shim (title_generator keeps its getattr fallback).
Re-remove shim-only names restored by 34abf954bd: latest_user_message_row_id (tests call
latest_message_row_id(key, role='user'); role-targeting assertions kept) and get_session_activity (tests
build the snapshot via agent.session_activity.build_activity_snapshot over db.get_session(sid)).
hermes_state_wal._log_once resolves its dedupe sets as module globals instead of via hermes_state;
hermes_state_repair helpers call module globals directly (tests patch hermes_state_repair.<name>).
Frozen updater surface untouched (update_cmd_maint imports only SessionDB from hermes_state).
run_agent.py: delete the `# noqa: F401` re-export block (agent.process_bootstrap
OpenAI/_SafeWriter/_get_proxy_*, model_tools get_tool_definitions/
handle_function_call/check_toolset_requirements, FailoverReason,
_qwen_portal_headers/_routermint_headers, session_persistence names,
estimate_request_tokens_rough, ContextCompressor + friends, jittered_backoff,
prompt_builder names, message_sanitization names, tool_dispatch_helpers
names) — 41 names run_agent never used itself — and the `_STREAM_DIAG_HEADERS`
back-compat class alias (no in-tree reader). run_agent now imports only what
it uses (get_toolset_for_tool, is_local_endpoint, coalesce/uniquify tool-call
ids, cleanup_vm/get_active_env from terminal_tool_lifecycle).
agent/*: `_ra().X` late-binds that only reached a re-export now import the
defining module directly (agent_runtime_helpers -> process_bootstrap.OpenAI,
model_tools.handle_function_call, session_persistence._safe_session_filename_component;
agent_init -> model_tools.get_tool_definitions/check_toolset_requirements,
_lazy_headers("agent.client_lifecycle", ...) for qwen/routermint;
system_prompt -> agent.prompt_builder / model_tools directly, dropping its
own _ra() shim and the `_r` parameter threading). `_ra()` stays for
run_agent-resident names (logger, AIAgent, _hermes_home, _set_interrupt, ...).
toolsets.py: remove resolve_multiple_toolsets (shim-only, restored by
34abf954bd); tests/test_toolsets.py pins the same union behavior via
resolve_toolset over each name.
providers/__init__.py: drop the OMIT_TEMPERATURE re-export (no callers via the
package); ProviderProfile stays because __init__ uses it for annotations —
2 tests repointed to providers.base.
agent/iteration_budget.py: drop the "run_agent re-exports the class"
docstring pointer; 4 tests import IterationBudget from its home.
model_tools.py (arg_coercion names), agent/tool_executor.py, and
hermes_cli/cli_session_mixin.py repoints landed via a sibling commit on this
shared worktree.
Callers repointed: gateway/run.py, hermes_cli/cli_chat_turn_mixin.py,
hermes_cli/cli_tui_mixin.py, tui_gateway/session_workdir.py,
agent/transports/codex.py (one-line imports) + comment pointers in
tools/file_state.py, tools/schema_sanitizer.py, scripts/tool_search_livetest.py.
Tests: patch("run_agent.X") / monkeypatch.setattr(run_agent, "X") /
`from run_agent import X` -> defining module across 99 test files.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.