React 18's root-level event delegation intercepts keydown events with
keyCode 229 (the "composition in progress" signal sent by the browser
during non-Latin IME input) and synthesises an onCompositionStart event.
That synthetic path sets internal composing state that interferes with
xterm.js's own IME handling on its hidden textarea, causing the first
keystroke of each composition chunk to be silently dropped.
The fix adds a capture-phase keydown listener on the terminal host div
that stops propagation of keyCode-229 events before they reach React's
delegation layer. xterm.js relies on native compositionstart/
compositionend on its internal textarea — not on keydown — so blocking
the propagation is safe.
Fixes#52111
- Remove unconditional 60ms wait that let deferred path pass sync-commit test
- Use fake timers (setTimeout/setInterval/Date only, NOT setImmediate)
- Assert immediately after final read — no trailing wait/advance
- Add deterministic coverage for 60ms fast-echo suppression reset:
* suppresses backspace after Ink repaint (IME recompose)
* does NOT suppress on normal ASCII typing
- Verified: revert sync commit -> deferred path makes 4/6 tests fail
Third-party Vietnamese IMEs (OpenKey/Unikey/EVKey in Telex mode) recompose
a syllable by emitting an erase burst followed by the finished characters.
Two layers of the TUI input pipeline mishandled this, dropping letters and
leaving a stray space mid-syllable (e.g. "hạnh" rendered as "hạ ", and
"vương sỹ hạnh" as "vương sỹ hạ ").
Root causes, both confirmed from real captured byte streams:
1. parse-keypress: an IME often fuses a control byte (\x7f/\b, or even the
U+202F marker OpenKey injects) with the recomposed text in a single stdin
read. parseKeypress only recognizes a control key when the whole string is
exactly that byte, so a mixed chunk fell through every branch, returned
name:"" with a non-printable sequence, and the composer's printable gate
discarded the entire chunk — taking the surrounding letters with it. Split
text tokens on every control byte so the printable runs survive.
CR/LF are deliberately not split, preserving paste/return semantics.
2. textInput: multi-character (IME/paste) inserts were committed through the
16ms deferred key-burst path, which raced an interleaved re-render and
snapped the buffer back to a stale value, dropping the recomposed tail.
Commit them synchronously. Additionally, the fast-echo "\b \b" backspace
shortcut desynced the screen when it ran right after an Ink repaint (forced
by the U+202F marker), stranding the marker glyph; suppress fast-echo for
the recompose burst that follows an Ink repaint and resume it on the next
real keystroke.
Tested with real OpenKey and EVKey captures of "vương sỹ hạnh" across read
timings, plus parser unit coverage and an EVKey no-regression guard.
Preserve printable IME commit text when xterm delivers it in the same input burst as Return, so Dashboard/TUI submits the visible draft instead of dropping the final segment.
Also fixes the TUI type-check stdio tuple typing and adds focused regression coverage.
Widen the composer-side IME fixes to the inline edit composer: the same
missed-compositionend wedge and post-compositionend keyCode 229 Enter
apply to its handleKeyDown path.
Chinese/Japanese/Korean IMEs emit keydown events during composition that
carry preedit keystrokes and the commit keypress (Enter/Space/Shift for
candidate selection). Treating them as combos fires unrelated keybinds —
e.g. typing 你 with a CJK IME could dispatch session.new and silently
open a new session.
Guard comboFromEvent():
- Bail out entirely while composing (event.isComposing or key === 'Process')
- Ignore keydowns whose event.key is a bare modifier name but whose code
is a regular key — legacy IMEs that synthesize keystrokes (Q9 2002 sends
key="Control" with code="KeyW") would otherwise canonicalize into
phantom combos like mod+w that close the active tab.
Tested with Q9 (九方) legacy IME on Windows.
A missed compositionend (focus jump, input-source switch, programmatic DOM
swap mid-preedit) left composingRef stuck true, and the stuck flag silently
swallowed every Enter in handleEditorKeyDown and every Send-button submit via
the form onSubmit guard — no error, no RPC, until the composer remounted. For
CJK IME users (where even ASCII typing runs through composition) this read as
"Enter has no effect; messages cannot be sent", degrading composer instance
by composer instance.
Recover in two places, both grounded in invariants Chromium guarantees:
- keydown: every keydown during a genuine composition carries
isComposing=true, so when the native flag says we're not composing, clear
the stale ref before the guard reads it.
- blur: a composition never survives focus loss, so clear the flag
unconditionally — this is what unblocks the Send button path, which has no
native composition flag to consult.
The genuine-IME protection (#37483 class) is untouched: Enter with
isComposing=true is still swallowed.
Fixes#44135
macOS Chinese IME (and some 3rd-party Windows IMEs) emit Enter with
keyCode 229 (legacy VK_PROCESSKEY) after compositionend, while
isComposing is already false. The existing guard only checked
isComposing and the composingRef, so this Enter slipped through and
submitted the message before the committed text was fully in the DOM.
Add an explicit keyCode 229 check in handleEditorKeyDown. keyCode is
deprecated, but it is the only reliable signal for this IME commit
Enter on Chromium-based browsers. Includes a dom-repro test that
simulates the macOS IME sequence.
Fixes: "中文混英文按 Enter 直接上屏"
Check event.nativeEvent.isComposing in Enter-to-submit branches so CJK (Japanese, Chinese, Korean) and other compositional input methods commit the candidate instead of firing the send handler.
Applied to all five sites in the desktop renderer that currently handle Enter with no composition guard:
- chat composer main submit and trigger popover (apps/desktop/src/app/chat/composer/index.tsx)
- message edit composer submit and trigger popover (apps/desktop/src/components/assistant-ui/thread.tsx)
- onboarding API key and auth code inputs (apps/desktop/src/components/desktop-onboarding-overlay.tsx)
- session rename input (apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx)
Fixes#37483
The salvaged kanban ConfirmDialog added confirmDoneMany/confirmArchiveMany/
confirmBlockedMany to types.ts and en.ts only; the strict locale type
requires every locale to carry them. English fallback pending translation.
A session deleted in the sidebar disappeared optimistically but flashed
back when any list fetch raced the in-flight DELETE RPC — the backend
page still carried the doomed row until the transaction committed
(#50928, reproduced with 'Load more' and auto-refresh). The optimistic
tombstone ($removedSessionIds) was only honored by the recents slice of
refreshSessions; the messaging slice, the per-platform pager, and
refreshMessagingSessions ingested backend pages unfiltered.
Extract the tombstone filter into dropTombstoned() and apply it at every
session-list ingestion point. Tombstones only exist while a delete or
archive is in flight (they self-clear on confirmation and are removed
immediately on failure), so non-destructive refresh paths are untouched.
Fixes#50928
Hermes Console registers `checkpoints prune`, `clear` and `clear-legacy` as
mutating, so it takes a console-level confirmation before dispatching any of
them. `_apply_confirmed_defaults` then exists to keep the CLI layer from
asking a second time — its docstring says so — but it only force-defaults
`clear` and `clear-legacy`. `prune` was left out, even though `cmd_prune`
gates its orphan preview on the identical `not args.force` shape.
`_capture_output` redirects stdout and stderr but never stdin, so the
unskipped `_confirm()` call hits `input()` with no terminal behind it:
`EOFError` propagates into `_confirm`, which returns False, and `cmd_prune`
prints "Aborted." and returns 1. The console turns that non-zero exit into a
ConsoleCommandError, so `checkpoints prune` fails outright for any user who
has at least one orphan checkpoint project — after that user already
confirmed. When the server does happen to inherit a foreground terminal, the
same call instead blocks a console worker thread and eats the operator's
keystrokes.
Forcing the flag is the documented behavior here rather than a weakening of
the recent orphan-allowlist hardening. `orphan_allowlist` binds a deletion to
the identities shown in the preview, guarding the window where a workdir
disappears while the command waits on `input()`. Under the console there is
no preview and no wait, which is exactly the `--force` case the comment on
`cmd_prune` describes as "no restriction".
Deleting a session in the desktop app fired instantly on click — the CLI
path (hermes sessions delete) asks y/N by default, so one misclick (Archive
and Delete sit right next to each other) permanently destroyed a conversation
with no dialog and no undo (#61470).
Route every delete entry point (sidebar rows, tab menus, the chat header,
context menus — all share useSessionActions) through a shared
DeleteSessionDialog built on ConfirmDialog. ConfirmDialog gains an
onOpenAutoFocus prop so dialogs with no input keep focus off the close
button (a11y).
Tests: menu delete now asks; cancel keeps the session; Enter confirms;
Escape cancels; delete item disabled without onDelete; the same guard
applies via SessionContextMenu.
Config settings auto-save on a 550ms debounce with no undo. The
'Enabled Toolsets' list is rendered by the generic ConfigField with no
destructive-change guard, so a stray select-all + Backspace (or any edit
that empties the list) is persisted the moment Settings closes —
silently disabling memory, terminal, web search, delegation, and most
tools. Recovery required CLI intervention.
Guard the one destructive transition: when the enabled-toolsets list
goes from non-empty to empty, window.confirm() before applying it (the
same pattern env-var removal already uses in toolset-config-panel.tsx).
Every other edit passes through untouched.
The decision is a pure helper (clearsEnabledToolsets) so it is unit
tested directly rather than through a full settings render. New i18n key
toolsetsWipeConfirm added to en + zh; partial locales inherit the
English string via defineLocale fallback.
Fixes#73319
Migrates 8 of 12 native dialog call sites in the kanban dashboard plugin
to the SDK's ConfirmDialog primitive (added in PR #50550):
- moveTask, moveSelected, applyBulk, deleteTask, deleteSelected,
archiveBoard, removeAttachment, doPatch
The 4 remaining carve-outs (window.prompt for completion summary,
window.alert for missing summary, cli_hint clipboard fallback) are
documented inline — the host's ConfirmDialog hardcodes onClick → unmount,
preventing the keep-open-across-validation behavior the completion-summary
form needs. Followup: upstream a `disabled` prop to ConfirmDialog and
rebuild the completion body using host Dialog components.
New architecture:
- useKanbanDialogs(t) — Promise-based dialog state machine at
KanbanPage scope. request({kind, ...}) returns {confirmed, summary?}.
- KanbanDialog component — renders ConfirmDialog from SDK for kind=confirm.
- performMoveTask(taskId, newStatus, count, summary) — extracted shared
dispatch path for single + bulk moves (optimistic UI + PATCH/POST +
error recovery).
- requestDialog prop threading — KanbanPage → BoardSwitcher,
TaskDrawer → TaskDetail → doPatch/AttachmentsSection. Every call
site has a defensive fallback to window.confirm if the prop is
missing (verified by test_dashboard_done_actions_prompt_for_completion_summary
counting the cancel guards + destructive:true markers in the bundle).
New host i18n keys (web/src/i18n/en.ts + types.ts):
- kanban.confirmDoneMany / confirmArchiveMany / confirmBlockedMany
- kanban.trash.confirmTitle / confirmManyTitle
Tests:
- Replaced bundle-string-only completion-summary test with behavioral
coverage: bundle cancel-guard count + destructive marker count, plus
backend tests that confirm cancel preserves old status and confirm
dispatches the expected PATCH/DELETE body.
- Removed the SDK_CONTRACT_VERSION snapshot test from
web/src/plugins/registry.test.ts (forbidden by AGENTS.md
"Don't write change-detector tests"; the two remaining tests in that
file already cover the new SDK surface behaviorally).
Closes#50547 (consumers of #50550).
Cross-vendor re-review: Gemini 3.5 Flash + GPT-OSS 120B (both SHOULD-FIX,
no remaining BLOCKERs after these fixes).
Additive expansion of window.__HERMES_PLUGIN_SDK__. Plugins can now render
host-styled dialogs, confirmations, and toasts instead of falling back to
window.alert/confirm/prompt.
New components: Dialog, DialogClose, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle, ConfirmDialog, Toast.
New hooks: useToast (replaces showToast/toast pair), useConfirmDelete
(single-id delete-confirm state machine).
SDK_CONTRACT_VERSION unchanged at 1.1.0 — additive surface per
sdk.d.ts:23-25 (no major bump required).
Consumer: kanban plugin's 'replace native dialogs' work, see issue #50547.
A reference prototype showing 4 design variants is committed at
docs/design/kanban-dialogs/index.html.
Adds web/src/plugins/registry.test.ts (3 vitest cases) that smoke-test the
new keys are wired and that the version constant is unchanged.
Reference prototype for the upcoming 'replace window.confirm/prompt/alert in
the kanban plugin' work. Self-contained HTML — open in any browser, no build
step. Served at docs/design/kanban-dialogs/index.html.
Four variants side-by-side, each rendered in the same kanban context:
- A (Conservative): direct host ConfirmDialog mapping, minimal chrome
- B (Strong-fit, Pro's pick): textarea + SVG icon + inline validation
- B-refined (synthesis, recommended): auto-focus, dual-validation,
cancellable spinner during PATCH, per-task summaries in bulk-many
- C (Divergent): undo toast for non-destructive moves
Decision matrix at the bottom of the page. Copy is verbatim from
web/src/i18n/en.ts (confirmDone, confirmArchive, confirmBlocked,
trash.confirm, completionSummary, etc.).
Design pass: Gemini 3.1 Pro (initial brief) + GPT-OSS 120B (cross-vendor
review). Not shipped to users — review reference only.
With 25+ sessions the recents list virtualizes into its own nested
scroller inside the sidebar's scroll container. Both carried
overscroll-contain, so once the inner scroller hit a scroll boundary the
wheel gesture was consumed instead of chaining to the outer sidebar
scroller — read as a mid-list wheel dead-zone while scrollbar drag kept
working. Drop the containment on the inner scroller only; the outer
sidebar scroller keeps overscroll-contain so the gesture still never
escapes the sidebar.
Fixes#84964
The salvaged #76716 adds git_metadata_generation to sessions (54 -> 55
columns). Update the synthetic-rebuild test's pinned widths and row
builders to the new current layout.
Creating a project whose resolved primary path already belongs to a
non-archived project now raises a clear ValueError naming the existing
project (create_project) — duplicated projects each seeded an identical
copy of the repo subtree, multiplying the duplicate-lane bug per copy.
The agent-facing project_create tool is idempotent instead: it re-activates
the existing project rather than erroring. allow_duplicate_path=True keeps
deliberate duplicates possible. Also updates the legacy non-git lane-id
expectation to the branch-style id introduced for #53329.
Non-repo explicit projects (plain folders) get a main-checkout lane whose
label is the folder basename, not a branch. Clicking "+" (new session) on
such a lane calls switchBranchInRepo -> switchBranch, which sanitizes the
basename to "" and throws "Branch name is required.", aborting the
session creation. Short-circuit switchBranch for roots that are not git
work trees so callers proceed with a plain session.
Fixes#83028
When a session's cwd moves from the main checkout to a newly created
worktree, overlayRepoLanes places it into the matching worktree lane but
never removed the stale entry from the main lane. The session appeared
under both groups until the user left and re-entered the project view.
Add a cross-lane eviction loop that removes the session from all other
lanes before inserting it into the target lane.
Live overlay always placed sessions under `::branch::main`, while the
backend non-git heuristic keys the lane by folder path (label =
basename). Overlay missed that lane by id/label and forked a phantom
`main` group with the same sessions.
Match existing path-keyed isMain lanes before creating a branch-style
main lane. Covers the codex-research-guardian-style drill-in duplicate.
_build_repos emptied each lane's sessions array for the overview (hydrate=False)
payload BEFORE _sort_lanes ran. _lane_sort_key derives a lane's activity from
max(_session_time(s) for s in group["sessions"]), so with the rows already gone
every non-trunk lane scored activity=0.0 and the sort key
(is_trunk, is_kanban, -activity, label) collapsed to alphabetical-by-label. The
documented intent — branches and linked worktrees sort by most-recent activity,
then label — was silently defeated on the projects.tree RPC that feeds the
desktop sidebar overview, while the drill-in path (hydrate=True) kept the rows
and sorted correctly. Any repo with two or more non-trunk lanes showed a
different order in the overview than when opened.
Move the session-clearing to after _sort_lanes/_disambiguate_labels so the sort
reads real recency. Lane counts are still captured before clearing, so
sessionCount and the slim overview payload are unchanged — only the order is
fixed, and the overview now matches the drill-in.
_place_by_heuristic used the raw path as the lane key for non-git
project folders, while the desktop overlay independently computed
::branch::main for the same session (since git_branch was null).
The ID mismatch caused duplicate lanes — one from the backend with
the folder name, one from the overlay labeled 'main'.
Use _branch_lane_id(path, DEFAULT_BRANCH_LABEL) so the backend's
lane key matches the overlay's expected ::branch::main scheme,
eliminating the duplicate lane.
A gateway active-session lease is acquired against the root HERMES_HOME,
but release_active_session()/transfer_active_session() re-resolved the
registry path from the *current* HERMES_HOME. Under native multiplex a
routed turn runs agent cleanup inside _profile_runtime_scope, so the
release looked under the named profile while the root entry stayed
alive — after max_concurrent_sessions routed turns every new session was
rejected with 'Hermes is at the active session limit' (#85431).
Pin state/lock paths on the lease at acquisition time and prefer them on
release and transfer. Fixes#85431.
Older nemo-relay bindings reject metadata= on scope.pop, which aborted
turn finalization and left scopes open. Filter kwargs to what the live
binding accepts so close paths can complete.
generate_base_drafts hardens every base draft to a transparent PNG with
_harden_transparency. When the provider returned a non-PNG file (webp,
jpg, or gif), the hardened PNG is saved under a new path and the original
draft is left in cache/images. Nothing prunes that directory outside the
gateway housekeeping loop, so a CLI or desktop draft round leaks one
original per non-PNG draft.
Remove the original after a successful hardening when the output path
differs from the input. PNG inputs (including mixed-case suffixes like
.PNG) are hardened in place so a case-insensitive filesystem cannot
treat with_suffix(".png") as a different file and unlink the output.
Each hatch generates one row strip per state into cache/images and
extracts the animation frames from it, but never removes the strip. The
only cleanup for that directory runs in the gateway housekeeping loop,
which a CLI, desktop, or cron hatch never starts, so the strips
accumulate for good.
Drop the strip after every attempt once its frames are decoded into
memory, including failed or retried attempts, so a hatch no longer grows
the image cache without bound.
The orphan reaper had two gaps that let agent-browser daemons accumulate
indefinitely inside a single long-lived hermes process:
1. `_reap_orphaned_browser_sessions()` ran exactly once, before the cleanup
loop started, so a leak appearing after boot could never be recovered.
2. `owner_alive is True` skipped unconditionally. In-memory session tracking
is lost on any exception path between spawn and registration, but the
owner PID stays up — so such a daemon was skipped forever.
The daemon-side `AGENT_BROWSER_IDLE_TIMEOUT_MS` is not a backstop for (2):
it does not fire when the daemon itself is wedged, e.g. after Chrome's
framework was replaced underneath it by an auto-update.
Observed on macOS: five agent-browser daemons (96 Chrome processes) built up
over 10 days inside an 18-day-uptime hermes process, holding roughly 5 CPU
cores busy and driving the load average past 100. Four of those processes
were still running a Chrome framework version that had since been replaced
on disk, spinning at ~85% CPU each.
Changes:
- Re-run the reaper every `BROWSER_ORPHAN_REAP_INTERVAL` (300s) from inside
the cleanup loop. Cycle 0 preserves the existing startup reap.
- When the owner is alive but the session is untracked, fall back to idle
age: reap past `BROWSER_ORPHAN_GRACE_SECONDS`, defined as
`max(1h, 20 x inactivity_timeout)`. Unknown age fails safe.
- Add `_socket_dir_idle_seconds()` — the newest mtime under a session's
socket dir. Every browser command writes `_stdout_<cmd>` / `_stderr_<cmd>`
there, making it a last-activity marker that survives hermes restarts and
does not depend on in-memory bookkeeping surviving an exception path. It
scans directory entries rather than reading the directory mtime alone:
command names repeat, and rewriting an existing `_stdout_click` updates
that file's mtime but not the directory's, so a dir-mtime-only check would
report a busy session as idle and reap it.
Sessions still present in `_active_sessions` are never touched at any age,
and the new path still goes through `_verify_reapable_browser_daemon`, so
the anti-spoof / anti-PID-recycle guarantees from #14073 are unchanged.
Adds 9 tests: idle-age unit tests (including the dir-mtime regression),
spared/reaped/fail-safe cases for a live owner, the identity-guard gate on
the new path, and a periodic-reap test asserting more than one reap per
cleanup-thread lifetime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>