Commit Graph

28271 Commits

Author SHA1 Message Date
Denis H 278c6ebcb7 test(dashboard): reproduce dropped dead-key composition input 2026-08-15 00:33:49 -07:00
liuhao1024 2af4da45ec fix(dashboard): prevent React from dropping first keystroke during IME composition
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
2026-08-15 00:33:49 -07:00
hanhvs e2f7850ec3 fix(tui): tighten IME test per teknium1 review (#55415)
- 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
2026-08-15 00:33:49 -07:00
hanhvs 5dec501c6e fix(tui): stop Vietnamese Telex IME from dropping characters
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.
2026-08-15 00:33:49 -07:00
InphinitiZ 834a9fc89d fix(tui): preserve IME text before return submit
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.
2026-08-15 00:33:49 -07:00
Cornna 084fca9dbf fix(tui): clear input after Korean IME submit 2026-08-15 00:33:49 -07:00
Teknium 15e7c563b3 style: eslint --fix on salvaged IME test (curly + padding rules) 2026-08-15 00:33:40 -07:00
Teknium 27a22b8de7 chore: map contributor emails for IME salvage 2026-08-15 00:33:40 -07:00
Teknium aa95939469 test(desktop): cover IME composition guards in keybind combo resolution 2026-08-15 00:33:40 -07:00
Teknium 587788405a fix(desktop): extend stale-flag self-heal and keyCode 229 guard to the edit composer
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.
2026-08-15 00:33:40 -07:00
BAS Lam 9f2e6d05ab fix(desktop): ignore IME composition keydowns in keybind combo resolution
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.
2026-08-15 00:33:40 -07:00
AIalliAI 39e7607794 fix(desktop): recover from a stale IME composition flag that wedged the composer
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
2026-08-15 00:33:40 -07:00
liyunlong 202e5b813a fix(desktop): block Enter keyCode 229 after IME compositionend
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 直接上屏"
2026-08-15 00:33:40 -07:00
izumi0uu 3d51c40997 fix(desktop): prevent IME submit in inline edit composer 2026-08-15 00:33:40 -07:00
Takumi Sato 20beee774c fix(desktop): prevent Enter from submitting during IME composition
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
2026-08-15 00:33:40 -07:00
Teknium 6fb6123f3b test: replace any-casts with typed globalThis narrowing in plugin SDK test 2026-08-15 00:33:32 -07:00
Teknium 3c2daf5ae7 i18n(web): add kanban bulk-confirm keys to all locales (English fallback)
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.
2026-08-15 00:33:32 -07:00
Teknium eaaecbcc30 chore: update contributor attribution map 2026-08-15 00:33:32 -07:00
Teknium 1025cb0e66 fix(desktop): stop deleted sessions resurrecting through racing list fetches
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
2026-08-15 00:33:32 -07:00
briandevans 4c24629bc9 fix(console): skip the checkpoints prune confirmation the console already took
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".
2026-08-15 00:33:32 -07:00
Gorde Minchel e6708af1f2 feat(desktop): confirm before deleting a session
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.
2026-08-15 00:33:32 -07:00
PRATHAMESH75 8e35ff0a62 fix(desktop): confirm before clearing the entire Enabled Toolsets list (#73319)
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
2026-08-15 00:33:32 -07:00
YuYigeng 7d34d7d8d1 docs(tui): clarify destructive confirm controls 2026-08-15 00:33:32 -07:00
YuYigeng 77f35add0c fix(tui): honor destructive slash confirmation config 2026-08-15 00:33:32 -07:00
David Metcalfe 08f32a6335 fix(kanban): replace native browser dialogs with in-app ConfirmDialog
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).
2026-08-15 00:33:32 -07:00
David Metcalfe 7448e7a506 feat(plugins): expose Dialog/ConfirmDialog/Toast/useToast/useConfirmDelete on plugin SDK
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.
2026-08-15 00:33:32 -07:00
David Metcalfe da68ecf4b3 design(kanban): add side-by-side dialog prototype (4 variants)
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.
2026-08-15 00:33:32 -07:00
Teknium 6b39ae490d fix(desktop): let sidebar wheel scrolling chain past the virtualized list
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
2026-08-15 00:33:22 -07:00
LeonSGP43 f080bc3db1 fix(tui): steady scrollbar after transcript shrink
(cherry picked from commit 40b0e93b12622679a34571fba04e13a27eec8e55)
2026-08-15 00:33:22 -07:00
Teknium d16326bb25 test: align lost-and-found schema pins with git_metadata_generation column
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.
2026-08-15 00:33:11 -07:00
Teknium e0e4d3ab9d chore(contributors): map salvage-branch contributor emails 2026-08-15 00:33:11 -07:00
Teknium cab6eb78f9 test(projects): widen lane-id derivation regression coverage
Cover the kanban ::kanban id, the -wt- suffix raw-path lane, and
Windows separator/trailing-slash spellings collapsing to one lane key.
2026-08-15 00:33:11 -07:00
Teknium f378a8fb3b fix(projects): dedup project_create by primary_path (#75820)
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.
2026-08-15 00:33:11 -07:00
embwl0x e89532d97e fix(desktop): order async session git metadata 2026-08-15 00:33:11 -07:00
Inspired-atm db1d8983e5 fix(desktop): no-op branch switch on non-repo project lanes
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
2026-08-15 00:33:11 -07:00
Mustafa d40dda7db6 fix(desktop): preserve lane session recency order 2026-08-15 00:33:11 -07:00
liuhao1024 3defb25a42 fix(desktop): evict stale lane entry when overlay moves session to worktree
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.
2026-08-15 00:33:11 -07:00
rsk-731 0fd223d1bd fix(desktop): stop dual main lanes for non-git project workspaces
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.
2026-08-15 00:33:11 -07:00
briandevans f7de2ca416 fix(desktop): order project-tree lanes by recency in the overview, not alphabetically
_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.
2026-08-15 00:33:11 -07:00
Tranquil-Flow 0d07fe63f9 fix(projects): use _branch_lane_id for non-git folders to prevent duplicate lanes (#53329)
_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.
2026-08-15 00:33:11 -07:00
Teknium 2fecf392a2 chore: map contributor email for Hangzian 2026-08-15 00:33:01 -07:00
Teknium 94ce8396e8 fix(sessions): release active-session leases against their acquisition registry
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.
2026-08-15 00:33:01 -07:00
rainbowgits aba4934274 fix(agent): omit unsupported metadata on Relay scope.pop
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.
2026-08-15 00:33:01 -07:00
Adolanium b9672ea24e fix(pets): remove the non-PNG base draft after hardening
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.
2026-08-15 00:33:01 -07:00
Adolanium 7de5a65906 fix(pets): delete row strips after extracting their frames
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.
2026-08-15 00:33:01 -07:00
andy c99a45b28e fix(browser): reap leaked agent-browser daemons whose owner is still alive
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>
2026-08-15 00:33:01 -07:00
Reksely 3d73821e9d fix(agent): stop thread output descriptor leaks 2026-08-15 00:33:01 -07:00
Teknium aa8e92f516 test: pin document.hasFocus in status timer test (useViewedInterval gating) 2026-08-15 00:32:53 -07:00
Teknium edd73daaf4 test(kanban): regression for idle-board WS disconnect detection (#77833) 2026-08-15 00:32:53 -07:00
Teknium f535a4f284 test(desktop): pin document.hasFocus in background-sync backstop tests 2026-08-15 00:32:53 -07:00