Wide markdown content forced horizontal panning: fenced code in tool
bodies, user bubbles and the bot group room grew sideways scrollbars (the
group room clipped with no scroll affordance at all), and huge plain-text
fallbacks never wrapped.
- markdown-text: min-w-0 on the container so the flex chain can't grow
from its content; HugeTextFallback chunks soft-wrap
- compact-markdown (tool bodies): fenced code soft-wraps
- user-message-text: fences soft-wrap inside the bubble
- group-chat-view: room bodies soft-wrap pre instead of overflow-x-auto
(the clip with no scroll affordance, #91706 surface)
- tables keep content-sized auto layout, so cells wrap at word
boundaries; an unbreakable token scrolls inside the table card, like
code cards do, instead of squeezing every column letter by letter
Co-authored-by: yuexiong <xiongyue_hnu@163.com>
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: networthexplained <networthexplained@users.noreply.github.com>
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
* feat(desktop): open new installs at a display-sized window and 110% zoom
A fresh install opened at a fixed 1220x800 and 90% zoom, so body text sat
below the Apple and Windows minimums and users pressed Cmd+ repeatedly.
The first window now takes 75% x 80% of the primary display's work area,
floored at 1220x800, capped at 1600x1000, and never larger than the work
area. A saved window-state.json still wins. The default zoom is the 110%
UI Scale preset; Cmd+0 / Actual Size resets to it. Existing installs keep
their saved zoom (first launch has always written the default to
zoom-state.json).
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* test(desktop-e2e): let a full-window overlay cover the viewport within 1 px
waitForInteractive treats a fixed element as blocking only when its rect
reaches innerWidth/innerHeight. innerWidth is rounded: at 110% zoom a
1280x860 window is 1163.6 CSS px wide and innerWidth reads 1164, so the boot
overlay (inset-0, 1163.6 px) no longer counted as covering the window and the
update E2E declared the app interactive while it was still booting.
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.
Hermes points TMPDIR at ~/.hermes/cache/scratch, so every Desktop test
run's leftovers pile up there until the 24h idle pruner catches them:
one day on a dev host left 688 playwright-tracing-*, 152
playwright-artifacts-*, 75 hermes-e2e-mock-* sandboxes and ~600 small
vitest mkdtemp dirs.
- Playwright (main + core configs): e2e/run-tmp.ts gives the run one
temp root that workers, Electron and the backend inherit via TMPDIR,
and removes it when the runner exits. Covers the stack-trace dirs the
Electron tracing patch orphans (2 per launch, even on green runs),
sandboxes a failed/timed-out setup never cleans, and Playwright's own
artifact dirs. A SIGKILLed run now leaves one pw-* dir for the pruner.
The update suite keeps os.tmpdir(): its seeded install is reused
across runs and bakes its path into AF_UNIX sockets.
- vitest (apps/desktop): globalSetup does the same for the run; one
`--project electron` run left 54 dirs from tests that never remove
what they mkdtemp.
The backend already reports {content, exists} from GET
/api/profiles/{name}/soul, but both SOUL.md editors read only content —
a profile whose personality lives in config.yaml
(agent.personalities.<name>) rendered a blank CodeEditor with no hint
that anything was missing or where the persona actually lives (#89436).
Both surfaces (Profiles view editor and the sidebar Edit SOUL.md
dialog) now show a localized notice when exists is false: no SOUL.md
exists yet, instructions below will create one on save, and config.yaml
personality presets are managed separately. The notice clears on a
successful save; an existing empty file or a read error is never
misreported as missing.
i18n: soulMissing added to every full locale (de/es/fr are override
locales and fall back to English). Component tests cover the
missing/empty/read-error distinction and cross-profile leakage; an e2e
spec drives both editors end to end.
The config.yaml read-back fallback and the dashboard source indicator
stay deferred to the personality-precedence work (#82801).
Salvaged from PR #105201 (author preserved).
Fixes#89436
On the error path posix.sh writes the result, relaunches the app, and then
sleeps 1.5 s to confirm the launch took. A relaunched app that boots fast logs
`detached update FAILED` inside that window, and the spec checked for a live
posix.sh right away. On #124878 the app logged at 08:34:06.565, about 0.5 s
after it launched, the check ran at 08:34:07.16, and posix.sh was still in
`sleep 1.5`. The spec now polls for up to 30 s.
Trail from CI: 78+ nested 'git fetch origin --filter=blob:none --stdin'
lazy fetches, each with its own upload-pack on the seeded origin, until
the runner is OOM-killed. The origin serves full clones, so nothing is
legitimately missing; GIT_NO_LAZY_FETCH=1 for the installer run only.
CI trail: the installer's --filter=tree:0 clone against the local
file:// origin fanned out into 1318 concurrent upload-packs and the
runner was OOM-killed ~90 s into seeding (local git never advertised
filter there, so local seeds were already full clones).
first-run: launch 2 drops .hermes-bootstrap-complete, so a usable install
must be recognised off the filesystem (the marker-gated resolver is the
sabotage that turns it red), and the chooser assertion runs before the
backend count so a red names the chooser.
update-concurrency: the About panel replaces the chat view, so the post-
update chat turn runs before the panel is opened.
Drive the packaged app a real scripts/install.sh + hermes desktop install
built, against a local bare origin, a local api.github.com stand-in and a
scripted model: first run, Update now hand-off/relaunch/chat, a failed
Desktop build inside that update, and a second update while one runs.
Open bugs are message-gated (gated on #122991).
Since the questions[]-only schema (#95907) every single question is a
one-entry batch on both the tool args and the gateway wire, yet no test
exercised that shape (called out in #98645). Lock the behavior down at
both layers:
- unit: a one-entry batch renders the batch card (not a blank/spinner
single card) both with the wire already parked and when the request
lands after the tool row, and answers with the qid-keyed lock
- e2e: a SINGLE_BATCH trigger drives the real chain (composer -> gateway
-> agent -> clarify tool -> clarify.request -> renderer) through mount,
pick, confirm, and settle for questions.length === 1
The e2e mock's trigger routing also learns to scope its has-tool-result
guard to the answering turn's own question text: the existing any-tool-
result check would false-positive once a second scripted clarify shares
the conversation history.
Adapted to main: the mock server now lives in tests-js/scripts, and a batch
confirm answers with clarify.lock.
Stop seals the live bubble at the click and mutateStream drops every later
message.delta (queued-but-unflushed ones too), but the agent keeps streaming
until it honours the interrupt and persists every delta it delivered. The
interrupted message.complete carrying that persisted partial was ignored, so
state.db (and the next turn's context) held words the user never saw.
The interrupted completion now extends the stopped bubble to the persisted
partial, or adds a bubble when nothing had been painted. Extend-only: a
shorter or different text never replaces what was shown.
Refs #121594
Upstream's e2e-desktop-core job provisioned setup-node + uv sync and pointed the
packaged smoke at a checkout .venv. Provision through setup-pm, hand its selected
interpreter to Desktop via HERMES_DESKTOP_PYTHON, and read the packager contract
from electron-builder.config.cjs, where the build config now lives.
remote-secondary: a bot on a remote secondary connection (connections.json)
with the local backend as primary; a client-only image must reach the remote
as image.attach_bytes. startRemoteBackend({hide}) covers client folders with
an empty tmpfs in a private mount namespace (unprivileged userns; annotated
'fidelity' when unavailable), used by both remote specs. remote-topology:
the post-restart state.db poll (which passed before the new backend opened
the DB) is replaced by a new-pid check + the restarted backend serving the
title after reload.
lineage-sidebar's #121148/#121088 steps never compacted (/compress was
refused as would_grow on a tiny transcript), so they could not fail.
lineage-rotation drives real auto-compaction with compression.in_place:
false (state.db-verified sealed parent + continuation) and asserts one
sidebar row per lineage after rotation, reload and cold relaunch; the live
nested-branch shape is KNOWN #121148. lineage-compaction-prompt compacts
inside an acknowledged redirect turn so the refresh passes through
preserveLocalPendingTurnMessages; the duplicate prompt is KNOWN #121088.
known.ts: expectNoSymptom marks a test expected-failing at run time only on
the bug's own assertion (a fix merging first stays green). lineage-sidebar
keeps the branch/switch scenario, drops the #121096 claim and now checks the
in-page duplicate sampler.
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.
A stalled stream burned the 600 s per-test timeout until the 30-min job
timeout cancelled the lane, and the failure()-only upload then saved no
traces. The CLI --reporter flag also dropped the config's html report.
The onboarding spec is a smoke test; #120005 needs a non-default profile.
Loop run 7 at 22e8184 went red on "no sandbox process survives quit #1":
a `git fetch origin --filter=blob:none --stdin` (+ its index-pack) was
still running 60 s after quit. That is git's lazy promisor fetch: the dev
checkout is a blob:none partial clone, and a git read by the backend
needed a missing blob, so git went to the network, and the fetch outlived
the backend (reported as a finding; same class as the gh probe). CI
checkouts are not partial clones, so the spawned app gets
GIT_NO_LAZY_FETCH=1: dev runs stay offline and deterministic like CI.
With no tirith on PATH (a CI runner) the backend installs it from GitHub
releases synchronously on the first terminal command, which puts a network
download inside a required lane. With one on PATH (a dev box) it fetched a
12 MB threat DB into the sandbox HOME that was still being written after
quit and cleanup (the stability loop left a recreated sandbox dir behind).
The approval prompts the suite asserts come from Hermes's own dangerous-
command detector, so the sandbox config turns tirith off, the same way the
suite fakes gh.
Fresh home, no provider: the real first-run onboarding, custom endpoint
pointed at the scripted provider, then the first chat, a second turn and
a reload, each under the transcript oracle (exactly once, DOM ==
persisted, streamed == provider at every sampled frame). Also asserts
the persisted assignment is the entered endpoint, the model is called
with the endpoint's advertised model, and one live socket remains after
onboarding: the path where #120005 (every word painted twice) shipped.
Red when onboarding persists the URL as typed instead of the probed /v1
base (#65488 class).
The boot census flagged "backend pids seen during boot: 2" in about 1 of
20 boots. Diagnosis (tight /proc watcher beside 20 repeats): every
`hermes serve` forks ~40 probes per boot (git, ps, ldconfig/gcc for
find_library, pip, docker version), and between fork and exec each child
still shows the backend's argv and HERMES_HOME; ~9 per boot were caught
in that window at 2 ms sampling, and the 100 ms census caught one every
few boots (the reproduced red: two extra pids with ppid = the backend,
one sample each). No double spawn by the supervisor was ever observed.
backendProcesses() now drops a serve process whose parent is itself a
serve process. A real second spawn has Electron main as parent, and a
pre-exec child orphaned by a dead backend is reparented and still counted.
The census also records ppid, argv and lifetime per pid so any future
extra pid explains itself in the failure message.
Under heavy load message.complete can trail the persisted row, so the wire
check is now a deadline poll run first (duplicate frames never heal, so the
poll cannot mask them) and the DOM oracle then covers whatever the renderer
does on completion. A boot that never becomes interactive now fails with the
blocking element chain, route, body text and the main-process log tail.
Clarify: one card, and the clicked choice is exactly what the model receives in
the tool result. Approval: the gated command has not run before the click and
has run after Run once; Deny never runs it and the turn still completes.
Parks the REST page read in main and the provider stream on gates so
message.complete lands before / after the switch-back hydrate
deterministically. complete-before-hydrate was 4/4 red on base (reply rendered
twice) and is green with the renderer fix.
The non-default-profile step now asserts the renderer holds exactly one live
socket to the host backend (red when 0bb539b472, #120006, is reverted); the
earlier per-turn check alone missed that regression. Transient-duplicate
violations now report which bubble ids carry the copies.
Class: Desktop boot / backend lifecycle - stuck boots, crash-loop or double
respawn, orphaned hermes serve / tool processes piling up after quit.
Real app + real backend: interactive composer and first turn with exactly
one backend; kill -9 the backend -> exactly one supervised replacement
(background /proc census catches transient extra spawns) and a working
turn; quit mid-turn with a running terminal-tool child -> zero processes
carrying the sandbox HERMES_HOME (orphans reparented to init included);
relaunch the same home 3x -> one backend per boot, zero after each quit,
transcript cold-hydrates exactly once.
Class: duplicate / vanishing / reordered messages in the Desktop (#120005
and its dupes #119131#119566#119540#118934; completed-reply refresh,
warm-resume, steer and reconnect regressions).
One real Electron app + one real hermes serve; only the LLM is faked by a
lane-private scripted provider keyed by each turn's own marker (no shared
trigger soup / global counters). After every transition - stream, tool-call
turn, reasoning turn, steer, queued follow-up, session switch mid-stream,
warm resume, reload, WebSocket drop+reconnect mid-stream (loopback proxy
the test controls), a non-default profile's chat, a forced second socket to
the same backend, final reload - the oracle asserts: persisted rows render
exactly once, in order, nothing unpersisted renders, no marker ever renders
twice even transiently, and the backend's concatenated deltas equal what
the provider streamed.
Why: the Desktop E2E job is disabled, so the 67 legacy specs run nowhere;
this is the small deterministic lane meant to be required (retries 0,
no fixed sleeps, one worker).
- tools/browser_tool_install.py: keep pm-clean's frozen old-updater stub; main's
UTF-8 decode fix touched only the npx prefetch body it replaces.
- tests/hermes_cli/test_update_scoped_reconciliation.py: keep pm-clean's test
subset (catch-up rides the PM completion owner) and take main's gateway-less
host evidence (#120740): the updated seed that holds the host at a running
gateway, and the two gateway-less matrices for the source change that merged
cleanly into update_cmd_fleet.py.
Independent-review follow-up for the group room failed-turn fix.
- Group room poll: a retained error newer than the pre-submit snapshot
(turn start replaces it with a fresh started_at) is this turn's failure
and wins over transcript text. The core closer writes no failed-turn row
behind a tool row, so "said X, called a tool, provider 401" left X as the
newest assistant row and the room posted it, dropped the 401 and re-drove
the member to the round cap (live repro on the PR head).
- Both pickers end the turn at a failed_turn row instead of scanning past
it; with the retained error gone (backend restart) the row's notice is
reported through the failed path instead of a silent pass / dropped
stranded marker. The stranded harvest treats a retained error as the
stranded turn's own the same way.
- REST cold-load/paging (/api/sessions/{id}/messages, /messages/around)
type legacy untyped notice rows like session.resume does, via one
read-side helper in agent/turn_failure_copy.py.
- Gateway closer: the fresh-session closure test now asserts the row's
display_kind through a real SessionDB round-trip (red without the
run_turn.py stamp).
- E2E: mock trigger that says text + calls a tool, then 401s; spec asserts
the room reports the error and never posts that text.
Both were deleted by #120071 as failing on main. Triage: group-member-backend-failure
was a real regression (fixed in the previous commit); group-approval-click-submits
only failed under a leaked HERMES_YOLO_MODE (harness fixed two commits up) and passes
on a clean env. Restored verbatim from d9ca819cc4.
A spec run from inside an agent terminal passed HERMES_YOLO_MODE,
HERMES_INTERACTIVE, HERMES_SESSION_ID... straight into the sandboxed backend.
HERMES_YOLO_MODE alone made group-approval-click-submits fail (the gated
rm -rf ran without a prompt), which read as an approval regression; CI never
has these vars. buildAppEnv now drops inherited HERMES_* except the
harness's own HERMES_DESKTOP_* / HERMES_E2E_* knobs.