A status filter sits beside the Messaging search, with a tab only for tones
some platform is in (errors, needs attention, connected, inactive). Picking a
tab moves the detail pane into the filter; a platform that leaves the filter
because you just toggled it stays open.
Messaging and the gateway menu now read platform state through one
classifier, so the same bot can no longer show two different tones.
Co-authored-by: Sonnenwerk <a.majuskel@gmail.com>
A message.interim redelivered after the first copy sealed the stream took the
standalone-append path and painted the same commentary twice. Refresh the
newest same-text assistant row of the current turn in place instead,
comparing whitespace-normalized text.
Supersedes #120145 and #93962.
Co-authored-by: Brammiebram <64777864+Brammiebram@users.noreply.github.com>
'data-drop-hover' is a generic token any component could style; rename to
'data-session-drop-hover' to match the session-drag module that owns it
(same attr constant in session-drag.ts) so no foreign CSS can hit-test it.
Moves to the dropped project row, and stays a no-op over Home and the
session's own current project — the same exclusions the row's existing
"Move to project" menu applies.
Extends the existing pointer-based session drag (session-drag.ts) with a
new resolvable target: a sidebar project row (tagged data-sessions-project
by ProjectOverviewRow). Dropping a session there calls the same
moveSessionToProject RPC the row's own "Move to project" context-menu item
already uses, so this is a faster path onto an existing capability rather
than a new one. Home and the session's current project are excluded as
targets, matching the context-menu's own filtering. The hovered project
row is highlighted imperatively (a data-drop-hover attribute + an
outline-sidebar-ring style) to keep the sidebar out of React re-render
churn during pointer travel.
Add _raw_config_has_enabled_moa_preset() guard to _moa_provider_row()
so the virtual MoA provider row only appears in model pickers when the
user's raw config.yaml explicitly enables at least one MoA preset.
The synthesized 'default' preset from normalize_moa_config({}) (which
every user gets via DEFAULT_CONFIG defaults) must not be treated as a
user choice — a user selecting 'openai-codex:gpt-5.6-sol' should never
see a transient switch to provider=moa.
The guard already existed in _filter_explicit_provider_rows() but was
missing from _moa_provider_row(), which is the entry point for the
non-explicit picker paths (TUI full picker, dashboard picker, etc.).
Adjust three tests that iterated CANONICAL_PROVIDERS (which includes
'moa') or used count-based assertions that assumed MoA was always
present.
2ffa4977ba made combo.ts import keybindActionAllowedInEditableTarget
from actions.ts, while actions.ts already imported IS_MAC from
combo.ts. When actions.ts is evaluated first, its KEYBIND_ACTIONS
table reads IS_MAC before combo.ts has initialized it. The dev
renderer throws "Cannot access 'IS_MAC' before initialization" and
#root stays empty.
actions.ts now reads the platform from the leaf module
@/lib/platform (isMacPlatform), which chords.ts and other keybind
code already use. actions.ts no longer imports combo.ts, so the
cycle is gone. The three defaults resolve at module load exactly as
before.
A turn that dies without releasing (SIGKILL, crash, parent killed with
children in flight) leaves its session_turn_leases row forever when
nobody resumes that conversation. Acquisition only reclaims the row of
the conversation it is acquiring.
Each acquisition now deletes rows that expired more than 24h ago, in the
same write transaction, through the existing expires_at index (which had
no reader). Recent expiries are kept, so a live owner with a starved
refresher can still renew its own row on its next write.
On a copy of a real state.db lease table, one acquisition removed the four
rows expired 16 to 25 days ago and kept the row expired 15.6h ago and the
live rows.
The update build called declared_extras() on every run, which reads
pyproject.toml even when no configured feature lacks its SDK (and product
build fixtures have none). The doctor web-surface probe decodes with UTF-8
like every other text-mode subprocess call.
execute_code imports third-party deps on a PM install, the PM runtime restages
through a pip mirror, and a launch on a read-only install tree fails once naming
the permission problem; the cells now assert it.
The isolation filter now drops only settings that redirect WHERE packages
resolve from (index URLs, find-links, strategy, index credentials); transport
knobs (UV_NATIVE_TLS / UV_INSECURE_HOST / UV_HTTP_TIMEOUT) survive — corporate
networks need them to reach the pinned wheel URLs at all.
Addresses the review note on the no_config scope in the #125071 triage.
stage_runtime (the pinned pm/ self-repair builder) passes no_config=True,
but that only appended uv's --no-config, which removes config FILES. The
pip-mirror bridge (pm.index_config) reaches uv as env vars instead, so a
user's pip.conf index-url still rode into the runtime sync via
runtime_environment() -> _base_environment(env=None). Re-resolving the
official-index pm/uv.lock against a mirror changes the resolution, and
uv sync --locked refuses — breaking the top-level update / doctor /
pm-repair recovery paths on any machine with a pip mirror (#124418).
When no_config is set, strip forwarded index/transport knobs from the
child env: a fully-pinned graph never needs a mirror to resolve, and the
default path keeps the bridge so mirrored/air-gapped application installs
still work.
<bootstrap>` launcher, but the persisted record still carried raw sys.argv,
which under that launcher is ["-c", "gateway", "run"]. _record_looks_like_gateway
could never accept it, so readers that fall back to the record (unreadable live
cmdline on Windows/EACCES, pid-file validation) still treated a healthy gateway
as foreign, and _gateway_code_root found no checkout path in it.
_build_pid_record now records the argv `python -m hermes_cli.main` would have
(argv[0] = the checkout's hermes_cli/main.py) when argv[0] is "-c"; the rest of
argv (including --external-supervisor) is unchanged.
The handoff E2E `head` relaunch cell already passes on main after #124965, so
its RELAUNCH_GATES entry is removed (the n1 entry is #124649, a different bug).
A PM environment build keeps the recorded extras selection and never reads
config, so a home with platforms.discord (or telegram) enabled came out of
`hermes update` without the SDK: the update child detected it
(_configured_features_missing_deps) and only printed a warning, and the
gateway's lazy install at connect time is the only recovery — which fails
silently when lazy installs are off or the process is not on the selected
generation.
The update-build child (fresh, selected interpreter) now maps each missing
configured feature to the pyproject extra named after it (platform name ->
extra, MCP -> mcp), keeps only declared, platform-supported extras, and adds
them with an explicit pm.sync_venv. Detection reuses the gateway's own
connected-platform semantics (config + env credentials), so it installs exactly
what the gateway will try to load on restart. Features it could not install
still get the warning; an install failure never fails the update.
Flips tests/e2e/core/upgrade/pm/test_configured_features.py::
test_configured_gateway_platform_has_its_sdk_after_update.
Keep the drifted fastapi/starlette pair (reported as an issue naming the
repair) and the probe never lazy-installing into the environment it
diagnoses; the absent-extra case is covered by the E2E drift cell. Also
fix the exit-code detail spacing and PEP 8 spacing after the check.
The optional `web` extra pins fastapi/starlette in lockstep, but the standalone
starlette security pin ships in several other extras on its own — so a managed
venv can end up with starlette 1.x beside an older fastapi. `hermes dashboard`
then dies constructing `FastAPI(...)` with `TypeError: Router.__init__() got an
unexpected keyword argument 'on_startup'`: not an ImportError, so the module's
own lazy-install fallback never fires, and the process exits before printing a
single line. `hermes doctor` had no probe covering this surface, so a dead
dashboard stayed invisible until the user needed it as a recovery path.
Add a doctor check that imports `hermes_cli.web_server` in a subprocess with
lazy installs disabled (read-only probe; it must never repair what it is
diagnosing) and reports the failing line plus the repair hint, or a soft warn
when the optional web extra is simply absent.
Fixes#124214
Codex webSearch items carry id/query/action; defaulting a missing status
to "completed" wrote a value codex never reported. Keep the provenance
marker, add status only when present, match sibling specs' ensure_ascii,
list webSearch in the module docstring, and pin args parity with the live
bridge (_codex_item_to_args) instead of a literal.
Codex app-server webSearch items fire the live tool bubble under
codex_web_search_<itemId>, but the projector had no entry for them, so the
stored transcript kept an opaque "[codex webSearch] {json}" note instead.
Any transcript refresh or reopen then replaced the search card with raw
JSON.
The projector now records webSearch as a web_search tool_call under the
same id and name the live bubble uses. The tool result is
{"provider": "codex", "status": ...}, so a later non-Codex turn can tell
Codex ran the search.
Salvaged from #66995: only the webSearch projection is kept, reworked
onto the _TOOL_PROJECTIONS table and the live web_search name. The MCP
elicitation trust change is not included.
/api/analytics/usage grouped sessions with date(started_at, 'unixepoch'),
which is the UTC calendar day. The Desktop Command Center and the web
Analytics page show that string as a calendar date, and the CLI /insights
command buckets the same sessions by the machine's local day
(datetime.fromtimestamp in agent/insights.py). For a user east of UTC, a
session started at 02:00 IST was counted on the previous day.
Add SQLite's 'localtime' modifier so the day is the machine-local day,
the same one /insights uses. SQLite applies it per row through the C
library, so each session gets the UTC offset in force at its own start
time and the buckets stay correct across a DST change.
Refs #23982
The cron SQLite stores write hermes_time.now().isoformat(), an ISO string
that carries the local UTC offset. That offset changes at a DST transition
and after a timezone config change, so the text order of these columns is
not their time order. Across a fall-back, 01:10-05:00 is 20 minutes after
01:50-04:00 but sorts before it. latest_execution(), latest_executions()
(which fills job["latest_execution"] for `hermes cron list` and the
dashboard), list_executions() and its before_claimed_at cursor, the
delivery queue's pending-claim order, delivery and execution pruning, and
list_incidents() all returned the older row first.
Every ORDER BY and the cursor comparison on these columns now use
julianday(col), which SQLite resolves to the same instant for any offset.
The raw text stays as the next sort key, so rows within the same
millisecond (julianday's resolution) keep their microsecond order, and the
cursor compares the same (instant, text) key on both sides. Stored values
and the schema are unchanged, so existing rows with mixed offsets order
correctly without a migration.
latest_executions() moves from a correlated per-row subquery to one
ROW_NUMBER() window. With julianday() in the ORDER BY the index on
claimed_at no longer serves the per-row LIMIT 1, and the correlated form
took about 90 ms at 1000 rows; the window form takes about 1 ms.
Refs #86520
Thanks to #86889 (zhao0112) for the executions-ledger diagnosis.
_session_start_like localised the naive session-id stamp with
datetime.now().astimezone().tzinfo, which is a fixed offset for today.
A stamp from the other DST half got the wrong offset: under
Europe/London on a summer day, session 20260115_003000_* rendered as
"Conversation started: Wednesday, January 14, 2026". dt.astimezone()
applies the offset in force at the stamp itself, as
gateway/message_timestamps.py already does.
Also replace the two remaining datetime.utcfromtimestamp() calls
(deprecated since Python 3.12) with fromtimestamp(..., timezone.utc).
The output strings are unchanged. tui_gateway/methods_session.py runs
with tui_gateway/server.py's globals, so server.py now imports timezone.
The qwen-oauth row in _PROVIDER_STATUS (hermes_cli/web_routers/oauth.py)
read three keys that get_qwen_auth_status (hermes_cli/auth_qwen.py) never
returns: expires_at, access_token and auth_store_path. The getter returns
expires_at_ms, api_key and auth_file. So the Accounts card for a logged-in
Qwen user always had no expiry, an empty token preview, and the generic
"Qwen CLI" label.
The row now reads the getter's keys. The epoch-ms expiry is converted to
an aware UTC ISO string, the same shape the nous and minimax cards send
and the type the web and desktop clients declare for expires_at.
The other rows (nous, openai-codex, minimax-oauth, xai-oauth) were checked
against their getters and already read keys the getters return.
`hermes logs --since` and `--level` passed every line that had no leading
timestamp. A traceback's frames are written without one, so an old error
printed its frames without the header that was filtered out, and
`--component` dropped the frames of a matching record. `hermes logs` now
reads each unstamped line as part of the record above it: the line gets
that record's verdict for every filter, in the tail read and in `-f`.
Lines before the first stamp in the read window have an unknown time and
level, so they are dropped when `--since` or `--level` is set.
mcp-stderr.log had no parseable stamp at all: the banner started with
`=====` and server output was copied raw, so `hermes logs mcp --since`
printed the whole file. The stderr tee already reads each server's
stderr in a thread, so it now writes one line at a time, each prefixed
with the asctime-shaped local stamp the Python logs use. The banner
starts with the same stamp. The stamp comes from new public
`timestamp()`/`stamp_line()` in hermes_cli/stderr_timestamp.py, which
stays stdlib-only. The desktop MCP log view accepts both banner shapes.
A test now requires a real writer's sample line for every LOG_FILES
entry to parse with `_parse_line_timestamp`. The docs no longer say the
`timezone` key changes log timestamps; log lines use the machine's
local time.
desktop.log stamped each line with an ISO-8601 UTC time in brackets
(`[2026-09-28T07:48:19.466Z]`), while agent.log, gui.log and errors.log use
Python's local-time asctime (`2026-09-28 13:18:46,062`). Reading one
incident across logs/ meant converting clocks by hand, and
`hermes logs desktop --since 1h` returned every desktop line, because its
timestamp regex expects the Python shape at the start of the line and
lines it cannot parse pass the filter.
formatLogStamp() now emits the Python shape in local time, and
formatDesktopLogLine() puts it first: `2026-09-28 13:18:46,062 [hermes] ...`.
The #84405 comment already said the stamp matched the Python convention;
now it does.
Under launchd the plist shell appends the gateway's stdout to gateway.log
(hermes_cli/gateway_launchd.py:237,367), the same file the gateway logging
handler writes (hermes_logging.py:371). print() output such as the WhatsApp
"Bridge started on port" line therefore landed there with no timestamp, and
`hermes logs gateway --since` kept those lines because it cannot date them.
stderr_timestamp already sits between launchd and the gateway and stamps
stderr into gateway.error.log. It now also pipes the child's stdout and writes
each line, stamped in the same asctime shape, to its own stdout. The file
targets, the plist and the detached fallback (gateway_launchd.py:296-299) are
unchanged, so installed plists pick this up without a reinstall. Lines that
already start with a stamp pass through unchanged.
The stdout reader is a daemon thread. After the child exits the wrapper waits
at most 5 s for it, so a grandchild that inherited stdout cannot keep the
launchd job alive past the gateway's exit; the exit-code mapping is unchanged.
The wrapper stays stdlib-only.
The relocated `_reap_unsupervised_gateway_orphans()` in `_cmd_restart` was a
bare call, so a reaper exception (OSError on the process scan) aborted
`hermes gateway restart` before `_service_call` — the best-effort wrapper the
Desktop backend had (`except Exception: pass`) was dropped with the move, and
so was the test proving it. Wrap it like the sibling in
`stop_profile_gateway()` (logger.debug) and restore the invariant as
`test_cli_restart_reap_failure_does_not_block_restart` against `_cmd_restart`.
Also move the reap to just after the refusal guards / `--all` branch so the
external-supervisor hand-back and the manual fallback (kind is None) are swept
too, not only the service-manager branch. The s6 dispatch and
`profile_lifecycle` returns stay unswept on purpose: s6 supervises the slot,
and profile_lifecycle only handles a profile whose `find_gateway_pids()` is
empty, so there is nothing for the scan to find.
Keep the (connectionId, profile) tile keying and its two invariant tests;
drop the post-switch remembered-route re-navigation (separate scope, hook
latch juggling) and the two extra delete-path tests.
The Desktop backend's `_spawn_gateway_restart` reaped "orphan" gateways
BEFORE spawning `hermes gateway restart`; the child then applied its
multiplexer refusal (`_guard_named_profile_under_multiplexer`, exit 78) and
started nothing, so a refused restart still cost the profile its gateway
(the reporter's 'refused restart, heartbeat stopped between two attempts').
Move the #77276 reap into the child, after the refusal guard, on the
service-manager branch (the manual fallback already reaps inside
stop_profile_gateway(); `--all` and the externally-supervised hand-back do
their own stop). The backend now stops nothing itself.
Test changes: `test_reap_failure_does_not_block_spawn` asserted the reap ran
in the backend (the bug); it is replaced by the two invariants: the backend
spawns without reaping, and the CLI reaps only after the guard.
Trim the salvaged detector/delivery pair to the salvage bar and fix the
delivery half. The PR resolved the refresh selection as
platform_toolsets.<surface> (desktop/tui), a key no config path writes,
so _get_platform_tools fell back to the constructed hermes-<surface>
composite that resolves to 0 tools (a cold resume went 30 -> 1 tool).
Delivery now goes through the builder new desktop/TUI sessions use
(tui_gateway.server._load_enabled_toolsets(platform) +
_load_disabled_toolsets) as an explicit refresh_agent_mcp_tools
override, so the refreshed set equals a fresh session's on that surface;
only desktop/tui agents are long-lived, every other surface builds a
fresh agent per process/turn and is left alone. Drops the
toolsets.py/tui_gateway policy relocation and the raw-value fallback in
the fingerprint; keeps the detector (platform_toolsets +
agent.disabled_toolsets replace the dead tools.enabled_toolsets key)
and the tools re-pin after the refresh.
Tests: 2 invariants in tests/agent/test_bot_chat_toolset_refresh.py
(desktop -> builder consulted, disabled override passed, re-pinned;
cli -> untouched) and the existing fingerprint axis test now edits
the key `hermes tools enable/disable` writes.
The capability epoch watched tools.enabled_toolsets, a key no surface
writes; real hermes tools enable/disable edits (platform_toolsets.* plus
agent.disabled_toolsets) never flipped it. Even when stale, the refresh
rebuilt only the prompt and never tools[]. Watch the real keys and
rebuild + re-pin the tool snapshot on Bot Chat capability refresh.
Closes#124211
Trim the salvaged handler/test prose to the salvage bar and make the
contract match the handler: SessionSetHiddenParams.hidden is required
(no default=True), so the generated TS/OpenRPC types stop advertising an
optional flag whose absence used to hide the whole compression lineage.
Regenerated apps/shared/src/gateway-contract.*.
A missing hidden param defaulted to True, so any caller that dropped
it silently hid the session plus its whole compression lineage
(set_session_hidden fans out via _set_lineage_column). Related to #122190.
Trim the salvaged change to the salvage bar:
- Tighten the comment at the new `_create_session` marker site to the WHY:
it records the same provenance `_apply_model_switch` records for a mid-chat
pick, so `_stored_session_runtime_overrides` can tell a chat-scoped pick from
an unmarked profile-following row instead of dropping model AND provider.
- Replace the contributor's record-shape test in the 22k-line
test_tui_gateway_server.py with one end-to-end invariant next to the existing
Bot Chat composer tests: `session.create` under owning profile B (launch
profile A has a different model) records B's model as the marker, the first
row write (`_ensure_session_db_row`) persists it into B's real state.db, and
the resume read under B's scope restores the picked model and provider.
Red on origin/main (KeyError: the record has no `composer_override_profile`,
so the row is written marker-less and resume falls back to the profile
default); green on head.
Class check (write paths that set a model override on a follow_profile_config
session): `_apply_model_switch` (mid-chat composer pick / /model via
config.set model), the three `session.resume` paths and `_persist_live_session
_runtime` / `_session_row_model_config` already carry the marker on main
(#116889, 53ac548f36); `session.create` was the only writer that did not.
Branch children (`_branch_live`, `session.branch_stored`) do not inherit
`follow_profile_config` or `model_override`, so they are out of the class.
Same outcome as the salvaged commits — a restored tab whose id every
profile answered 404 for in calm conditions is dropped instead of being
re-probed on every launch — with less machinery:
- one set of scope listeners per backfill run instead of six per tile;
retired ids are tracked by storedSessionId, so a tile array rebuilt by
the backend-identity guard between start and gateway-open cannot make
the retire silently never fire.
- the "profile inventory known" gate lives in probeStoredSession, where
the ladder decides what its evidence proves, instead of in the caller.
- the nine-case test file is folded into session-tile.test.ts as two
invariants: retire on all-profile 404 (red on origin/main), keep on
5xx / network failure / switch in flight / profile A→B→A / connection
switch / stashed draft / unknown inventory.