feat(whatsapp): observe unmentioned group messages - #1
Draft
lisajlau wants to merge 2344 commits into
Draft
Conversation
૮ >ﻌ< ა ci reviewran on 3487d9b — update contributor ❌ Job failuresPython tests / Run tests slice 2/12 · View jobJob Python tests / Run tests slice 2/12 failed.
|
lisajlau
pushed a commit
that referenced
this pull request
Aug 20, 2026
Two independent bugs let a deleted profile reappear / leave orphaned resources on next launch: 1. hermes_cli/profiles.py's backend-process scanner required argv[0] to resolve to an executable literally named "hermes". Electron's pool-backend spawn resolves the hermes console-script shim's path and execs it via the interpreter directly (python3 /path/to/hermes ...), so argv[0] reports as "python3" and the scanner never matched the running backend -- delete removed the profile's files but left its live backend process running (still bound to a port via uvicorn), which accumulates across repeated delete/recreate cycles. 2. The desktop sidebar's ProfileRail only refreshed its cached profile list once, on mount, so a delete/create/rename from another surface (another window, or the CLI) left a stale ghost entry until something unrelated triggered a refetch. Note: a delete via this window's own Manage-Profiles view already refreshes the shared $profiles atom ProfileRail subscribes to (confirmed by reading refreshProfiles() and handleConfirmDelete()) -- this fix only covers the cross-window/cross- process staleness gap, not a duplicate of the already-merged NousResearch#57329's Manage-Profiles rail-refresh work. Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named console-script shim via argv[1]. Fix 2: refresh the profile list on window focus/visibilitychange, matching the existing pattern used elsewhere in the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx, use-gateway-boot.ts all use the same focus+visibilitychange pattern). ## Related work already on main PR NousResearch#57329 (merged) fixed the *headline* symptom from issue NousResearch#52279 (deleted profile respawns) via a different, non-overlapping mechanism: routing profile-delete through the primary backend instead of spawning a fresh pool backend, plus a separate recreation guard in ensure_hermes_home() (NousResearch#49435, merged) that makes a backend spawned into a deleted profile's directory raise FileNotFoundError instead of silently recreating it. This PR is NOT a duplicate of that fix. Verified: even with both of those merged, a backend process that survives because of gap #1 above still holds a bound port via uvicorn -- it just can no longer resurrect the profile directory. That's real resource-hygiene, not a symptom already covered. Gap NousResearch#2 touches a different file/component (ProfileRail / profile-switcher.tsx) than NousResearch#57329's rail-refresh half (which touched the Manage-Profiles view's own $profiles.ts / index.tsx) and covers a distinct staleness path (cross-window/cross-process, not same-window delete-then-refresh). Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing + regression coverage for the argv[0] python-interpreter detection case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unscoped getSession hits the primary backend. A 404 then skipped the active profile in the remaining probes, so chats on a non-default profile never loaded. Co-authored-by: Michael McAllister <michael@empowerlo.com>
Grouping → Profile persists ALL even with a single profile. Recents filtered that pool against the __all__ sentinel and emptied the list. Cron and messaging already used filterSessionsByProfileScope; recents now does too. Co-authored-by: andyst-dev <150129844+andyst-dev@users.noreply.github.com>
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a first-party equivalent in setBackgroundMaterial, so the mode now resolves its backing per platform instead of per-OS-check: macOS keeps vibrancy, Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on Clear. No third-party native addon. Two Windows-specific details the mapping has to respect. DWM only paints the client area of a transparent window (electron#49443), so glass-capable Windows chat windows are born transparent with the opaque themed backgroundColor covering them while glass is off — a live Clear/Glass toggle then needs no window recreate. And Windows exposes three backdrops for four frost rungs, so the two heaviest both resolve to mica; the mapping stays total so a frost saved on a Mac still renders. Glass support is computed once from os.release() and shared: main uses it for the persisted default and every window, preload publishes it to the renderer so the UI can't offer a mode the window can't back.
The row offered one unlabelled 0-100 slider whose meaning changed with the mode. Under Clear it is window opacity; under Glass it was never opacity at all — it sets how much of the theme tint stays painted over the material. Same track, same percent readout, two different things. Glass now gets a labelled panel: Tint keeps the renderer lever, Fade is a real native opacity on the ramp Clear uses, defaulting to 0 because fading a glass window fades its text — the thing Glass exists to avoid. Frost offers only the rungs the OS renders distinctly, so Windows shows three instead of two buttons that composite identically; a frost saved on a Mac highlights the button that renders the same backdrop rather than leaving the picker blank, and is not rewritten. Linux loses the row entirely, from the page and from settings search. setOpacity is a documented no-op there and there is no material, so both halves were dead — a lever that moved a number and changed nothing.
Deciding whether the OS can back glass needs os.release(), but every Hermes window runs its preload with sandbox: true, where require is a polyfill limited to electron, events, timers and url. The node:os import threw before contextBridge ran, so window.hermesDesktop was never defined and the app booted straight into "Desktop IPC bridge is unavailable". Main already computes both verdicts, so preload asks for them over a synchronous channel instead. No reply degrades to no glass, which is an ordinary opaque window rather than a page thinned over nothing.
Perfectionist wants values before types in the glass/Windows barrel.
…to dim The placeholder hint and its synthetic cursor chip hand-rolled truecolor escapes ([38;2;r;g;b / [48;2;r;g;b]) and wrote them raw past Ink's depth layer. Legacy Terminal.app has no truecolor parser — it walks compound params one by one, so the literal 2 in 38;2;… lands as SGR 2: dim ON, with no 22m ever emitted. Every frame that painted the placeholder left the terminal's dim attribute stuck, and subsequent cells rendered dimmed until an unrelated bold span's 22m happened to clear it — text randomly flipping dim and back, worst right after the composer empties. Measured on a live resumed session (PTY capture, params interpreted the legacy way): 1026 glyphs painted with stuck dim on main, 0 with the fix. Route both helpers through Ink's own colorize, the same repair colorizeEcho got for the fast-echo path (gray-accent bug) — the escape now downgrades with the terminal's real color depth, and a 256-color terminal gets 38;5;N it can actually parse. Also harden hermes-ink's transitionAnsiCodes for compound SGRs: real tool output ships [1;31m-style sequences whose endCode is [0m, dodging the endCode-based weight detection — parse the params instead (skipping 38/48 extended-color arguments) so a compound bold→dim transition passes through SGR 22 too.
eslint no-control-regex rejects the CSI regex even though ESC is the sequence we have to parse.
2,248 lines of gateway REST client become twelve modules by domain, with hermes.ts left as a barrel so all 144 importers stay put. The import graph is a star — every domain module imports only ./client, and client imports nothing back — so there are no cycles. The barrel names client's public exports rather than re-exporting it wholesale. Splitting a module forces its private helpers into exports so siblings can reach them, and export * would then republish them: profileScoped, connectionScoped and capabilityScoped were private to hermes.ts and have to stay that way, or a call site can assemble its own request scope and drift from the api layer.
…ules The monolithic if/else-if dispatcher becomes nine modules by event family. The routing preamble runs once, then each handler consumes its own types and reports whether it did, so dispatch stops at the first taker. Families are mutually exclusive by type, so ordering between them is inert; ordering within a family is unchanged. Restores two things the extraction dropped against a moving base: the layout.apply handler, and the multi-question clarify.request path. A batch clarify was consumed and never parked, so the agent blocked on clarify.respond with no card rendered — the existing tests passed because they assert "exactly one clarify card", which is also true when the request is dropped and only the tool.start row exists.
Types, part builders, tool parts, hydration and reconciliation, behind a barrel that keeps the @/lib/chat-messages path. The folder was added without removing chat-messages.ts, so resolution preferred the file and all its importers kept hitting the monolith while the new modules sat dead. Deleting it surfaced a missing preset field on GatewayEventPayload that layout.apply needs, hidden until the folder actually resolved, and a completeOpenStreamParts helper copied into two modules when only one calls it.
52 handlers move into five registrars — git, pet overlay, hud, fs and terminal. Each takes injected deps (window handles, binary resolvers, path hardening) following the existing electron/ module pattern rather than closing over main.ts locals, and terminal-ipc returns its dispose helpers so SSH teardown and app shutdown keep working.
Splitting the god files made a pile of copy-paste helpers visible and, for the first time, fixable — sharing them previously meant importing a god file. Hashing function bodies through the TypeScript AST found twelve groups desktop-wide; production code is now at zero duplicates. Each helper went to the module that already owns its concern: firstStringField to lib/text, the two REST 404 predicates to lib/gateway-rpc beside isMissingRpcMethod, useDebounced and prefersReducedMotion to their hooks, the superseded-bootstrap guard to electron/ssh-connection, the composer keyup handler to the trigger hook that owns the rest of that state machine, and clampDataUrlReadMaxMb to apps/shared, replacing a "keep these in sync" comment between two copies. Only helpers with no existing owner got a new file: lib/mcp-servers, lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command palette's status row. Error-shape predicates are the worst thing to copy — when the backend changes how it reports a missing route, every copy has to be found.
Comments naming gateway-event.ts, chat-messages.ts and hermes.ts as the place to look, for files those symbols no longer live in.
The same AST sweep over specs found fixtures maintained in parallel across suites that have no reason to know about each other. Twenty-one specs each mounted useMessageStream themselves and ten of the harnesses were byte-identical; twenty now take renderMessageStream, with overrides for the seams that genuinely vary. The SessionInfo builder was spelled out field-by-field in seven specs, so a new backend field broke seven files instead of one. Twenty specs carried their own inert ResizeObserver and eleven repeated the animation-frame, CSS.escape, scrollTo and WAAPI stubs the transcript needs to mount at all — split by scope into src/test/jsdom for what any component might need and the assistant-ui folder's own kit for the transcript. Plus the window-state bridge, deferred, the external-store thread runtime, the manual createRoot harness, and the per-folder caret, env-var, provider and session fixtures. Left alone on purpose: the store suites' makePrimary, where the vi.mock harness around it is the actual duplication and cannot be hoisted out of a hoisted factory; electron's deferred, where reaching into src/ from the main process would invert the layering for eight lines; and the two suites that compose another hook alongside the stream.
Review "Ask Hermes to open PR" was a window-level event that every mounted composer claimed with `target === 'main'`, so one click shipped every open session and project with dirty files. Bind the request to the visible surface captured at click time. Co-authored-by: unsupportedpastels <theoldwizard123@pm.me> Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
The ship button always targeted `main`, so a tile Review still prompted the workspace session. Remember the originating composer target with the pane's cwd, capture the live surface at click, and toast if that chat isn't on screen instead of dropping the click. Co-authored-by: unsupportedpastels <theoldwizard123@pm.me> Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
The renderer's `tour.request` handler ships in the desktop bundle, but the tool is offered by the backend, and the two update on different clocks. A desktop build older than the tour tool receives the event in a renderer with no branch for it, so `tour.respond` never comes and the agent blocks for the full 45s deadline — once per action the model tries. A single "give me a tour" turn (targets, then narrate, then stop) stacked those waits into minutes of dead air, which is what got reported against NousResearch#89620. Hold a session's first action to a deadline a working renderer cannot miss, and let an unanswered probe mark the bridge unavailable for that session: later calls return immediately with an error naming the actual fix instead of stalling again. Once a client has answered, real actions get the full deadline back, so a preview tour injecting into a live page still works and one slow action no longer condemns a live client. The verdict lives on the session record, so it dies with the session and a new one re-probes. The same five-action sequence goes from ~225s of dead air to a single 10s probe. Toolset gating is unchanged: removing the tool outright needs a client capability declared at session.create, which prompt caching means can only take effect for a new session.
AlertTitle clamps to one line, so Desktop error toasts hide the rest of the message behind an ellipsis. Override that clamp, let the title wrap, and cap height so a huge error scrolls instead of covering the chat.
Cover the wrap override and height cap so a one-line clamp cannot hide the rest of an error toast again.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Grok Imagine Image 2.0 (ceabb03) intentionally ships upscale: True — its 1k native output is sub-2MP. Per-entry defaults are a catalog decision now; the blanket all-off invariant no longer reflects policy. Per-call upscale=true/false override is unchanged.
`do_uninstall` already accepted a `skip_confirm` parameter and the slash-command handler already passed `skip_confirm=True`, but the CLI argparse path never exposed a flag to reach it. This adds `--yes`/`-y` to `hermes skills uninstall`, matching the existing pattern on `install` and `reset`.
…flag Covers the new --yes/-y flag on , asserting the parsed value reaches do_uninstall(skip_confirm=True) via the real main() -> cmd_skills -> skills_command dispatch path. Mirrors the install-flag test pattern in test_skills_install_flags.py.
Widen the cli.py Ghostty exception to the sibling sites the review found: the Ink TUI pushes CSI >1u at raw-mode entry (App.tsx), on alt-screen exit, and on the extended-keys re-assert path (ink.tsx) for every EXTENDED_KEYS_TERMINALS entry including ghostty - same Alt-stripping bug. New skipKittyKeyboardProtocol() helper in terminal.ts gates the ENABLE push at all 3 sites; the DISABLE (pop) stays unconditional since popping an empty stack is a spec no-op. Also fix the cli.py comment citing the modifyOtherKeys encoding where the kitty CSI-u form (ESC[127;3u) is what the broken path expected, dedupe the quadruplicated Ghostty comment, and update the stale 'mirroring the Ink TUI' docstring. 7 new vitest cases.
check_memory_requirements() reads the readonly config loader; the salvaged tests only patched load_config, so the gate saw the real config.
… surfaces Follow-up to PR NousResearch#90183 — computer_use now saves a bounded shareable copy of image captures, so attachment-capable surfaces (Telegram, Discord, Desktop) can deliver the real screenshot when the user asks. Documents the behavior, the 20-file cache bound, and the no-automatic-send rule.
…Lock kitty and ghostty OR the CapsLock (64) / NumLock (128) state into the CSI-u modifier parameter. With NumLock on, Ctrl+C arrives as ESC[99;133u (5 + 128) instead of ESC[99;5u; the alias table had no entry for it, so every key combo leaked as literal text like [127;133u (NousResearch#89651). Install every CSI-u alias with the lock-bit variants (+64/+128/+192); the xterm modifyOtherKeys encoding never carries lock bits, so the ESC[27;N;CP~ form is left untouched. The Esc-key registration now covers modifier 1 as well (1+128=129 is a lone Esc with NumLock on).
…ocol With the kitty keyboard protocol push active (CSI >1u disambiguate), kitty encodes lock-key state into the CSI modifier field of function keys: a plain Down with NumLock on arrives as ESC[1;129B (NumLock), ESC[1;65B (CapsLock), or ESC[1;193B (both) instead of the legacy ESC[B. Stock prompt_toolkit maps none of these, so the parser fires Escape and inserts the remainder as literal text in the input line. 03bf85d restored the kitty push and completed the extended-key alias table but left these lock-bit variants unmapped, so kitty + NumLock still leaks [1;129A/B for arrow/nav keys. Map modifier 129/65/193 (plain) and 130/66/194 (+shift) for arrows, Home/End, Insert/Delete/PageUp/PageDown, and CSI-u Enter/Tab/Backspace/ Space to their plain keys.
…s, and PUA functional keys Follow-up to the salvaged NousResearch#89676 + NousResearch#90291 lock-bit fixes: extract a shared _lock_variants() helper and cover the sites both PRs missed - install_shift_enter_alias / install_ctrl_enter_alias / install_cmd_backspace_alias CSI-u spellings, legacy CSI-letter and CSI-tilde navigation twins derived from the existing table for ALL modifiers 1-16 (not just plain/shift), plain F1-F4 SS3 fallback, unmodified CSI-u keys (Tab/Enter/Space/Backspace), and kitty PUA functional keys (keypad, F13-F24, Ignore range) under lock bits. 8 new tests.
… forms, _lock_twins idiom Post-review cleanup on the salvage: update the three alias-installer docstrings to mention lock twins (and fix ctrl-enter's stale 'stock maps none of these' claim - stock maps the tilde form to plain ControlM, which the overwrite fixes); skip the never-emitted modifier-1 tilde forms in _install_paired; name the twins-only idiom as _lock_twins() and use it at the legacy-nav + PUA sites; route the Esc loop through _lock_variants; hoist the per-base table lookup out of the lock loop in the legacy-nav section.
…orktrees and merged branches The startup pruner is deliberately conservative (unattended, pre-banner), so real installs accumulate what it can never touch: trees preserved for untracked-only scratch, and orphaned local branches beyond the two auto-generated prefixes it deletes. A measured multi-agent box: 35 trees / 15GB / 244 local branches, 120 of them fully merged. New attended surface (hermes_cli/worktree_gc.py + worktree_cmd.py): - hermes worktree list — audit every tree: age, size, verdict, reason, plus deletable-branch count - hermes worktree prune [--dry-run|--trees-only|--branches-only] - /worktree prune [--dry-run] — same engine in-session; never touches the session's own active tree - startup escalation: one WARNING when .worktrees/ exceeds 10 trees or 5GB, naming the reclaim commands (silence is how boxes hit 15GB) Safety invariants (shared with the startup pruner via cli.py primitives): tracked modifications and unique unpushed commits never deleted at any age; live-locked trees untouched; branch deletion gated on worktree removal success; untracked-only scratch ARCHIVED to ~/.hermes/archive/worktree-prune/ before its tree is reaped. Branch GC is content-gated, not name-gated: any local branch fully merged or git-cherry patch-equivalent upstream is safe to delete (rebase merges rewrite SHAs, so --merged alone misses the dominant leak); unique-commit, checked-out, protected, and stale-base (>50 ahead) branches are kept. Classification is parallel (8 workers) — 244 branches audit in ~64s live. git timeouts degrade to keep (returncode 124) instead of crashing the audit — live-verified failure on a 746MB .git repo. 16 behavior-contract tests against real git fixtures; live dry-run on the production repo: 12 trees reclaimable, 120 branches deletable, 0 false positives among kept trees.
…e add survives disk contention Two gaps behind the recurring 'hermes -w timed out after 30 seconds': 1. Rebase-merge leak: git cherry only catches patch-identical commits. Salvage flows routinely change the diff (conflict resolution, follow-up commits), so 12 of 22 'unpushed' trees on the incident box had MERGED PRs and were preserved forever. The pruner now falls back to 'gh pr list --head <branch> --state merged' — authoritative, memoized on (branch, head_sha) with True-only caching, fail-safe to preserve. 2. Creation timeout 30s -> 120s: the ~10k-file checkout measured 113s at near-zero CPU under multi-agent disk contention vs 1.2s idle. 30s killed legitimate creates and threw away completed work.
…ion is disabled (NousResearch#89297) When compression is explicitly disabled (compression.enabled: false), conversations can grow past the model's context window across hundreds of messages (e.g., 824 messages / 460K+ tokens in NousResearch#89297). Serializing massive JSON payloads repeatedly under memory-constrained environments leads to swap thrashing (STAT=U) and unhandled provider errors. Add a pre-flight uncompressed context overflow guardrail in build_turn_context and a deduped _warn_uncompressed_context_overflow method on AIAgent to alert users to run /compact or enable compression before unmanageable payloads freeze the process.
…site + re-arm Review follow-up on the salvaged NousResearch#89444: - Warn fires only from the conversation-loop pre-API site, reusing the unconditionally computed request_pressure_tokens (zero marginal cost, covers turn-start AND mid-turn growth) — drops the duplicate every-turn estimate the turn-context block paid. - Turn-context block now only RE-ARMS the dedup once the session is back under the window, so warn -> /compress -> regrow warns again (the dedup was previously never cleared with compression disabled). - Char pre-check treats non-string (multimodal) content as over-gate — len() of a part list defeated the 20k char floor (probe: 10 'chars' vs ~70k real tokens) — and compares against the window, not a flat 20k. - Deletes the unreachable get_model_context_length fallback from both sites (context_compressor always exists; its context_length property hard-floors positive; the fallback would have been a synchronous network probe mid-turn that also bypassed config overrides) and the undeduped inline _emit_warning fallback (third copy of the message). - Tests bind the PRODUCTION warn/clear methods (previously a verbatim fake reimplementation left them uncovered) and add dedup, re-arm, no-rearm-while-over, and multimodal-gate coverage.
…n content The surface is a grid that never declared a column, and an implicit auto column sizes to its items' min-content. The coding status row (branch, PR chip, worktree path, counts — none of it wrapping) out-measured narrow panes and silently set the track wider than the surface; every w-full child laid out against that phantom width and overflow-hidden clipped the right edge, send button first. grid-cols-[minmax(0,1fr)] pins the track to the surface.
Stacking was the ladder's last rung, but a tile can be dragged far narrower than the stacked controls row costs. Two more width stages, from the same measured-width engine: under 260 the three voice toggles fold into the one menu HUD mode already uses, and under 180 the model pill and the menu drop too — input and Send, nothing else, down to the 80px pane floor. The model pill also shrinks and truncates between stages instead of holding its width, and the metrics hook returns one ComposerFit so a resize re-renders only when a stage actually flips.
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name) changed the roster row but not what the user could @-tag it with — mentions still only resolved the original profile handle, and the composer autocomplete never offered the new name. - mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for the taggable forms a friendly name yields (slugged + collapsed), with reserved tokens (hermes/default/everyone/all/user) excluded so a rename can never hijack them. - resolveRosterMentions() and parseGroupChatMentions() accept the friendly forms alongside the profile name/handle (both keep working). - Composer @ autocomplete (global provider + group-room popover) inserts the renamed tag and prefix-matches on tag, handle, and display name. - Mention middleware's cold-cache fallback now runs the same resolver instead of a bare-names-only parse, so renamed tags resolve there too. - durableGroupChatMembers persists title/display_name so renamed-tag mentions survive connection switches in cross-machine rooms. - Docs: bot-mode.md documents renamed tags.
…ely dedupe to stubs
The bundled skins were an ad-hoc set that had drifted from anything recognisable. They are now forks of the VS Code themes people already know, produced by the repo's own marketplace converter rather than transcribed by hand, so each palette is byte-identical to what installing the extension would give you. `nous` keeps GitHub's chrome and carries the brand blue as its accent. Two seeds, one colour: `#0053fd` reads at 5.4:1 on the light sidebar but only 3.6:1 on the near-black dark one, so dark carries `#4a84fe` — the same hue at 263°, lifted to clear AA at 5.9:1. Everything else in both palettes is upstream's, and a test holds that line. `github` ships alongside it, unmodified, so the original stays available on its own terms instead of only existing as the thing nous diverged from. Catppuccin, Everforest and Solarized join them; the skins nobody could name are retired, with `midnight` folded into the retired list so anyone sitting on it lands on nous rather than a dead name.
A palette's accent is not one value, it is a family: the seed plus the soft surfaces mixed from it — seven slots per appearance in nous, all derived from one colour. `retintTheme` moves the whole family at once, reusing the converter's own mix ratios so re-seeding a theme with its existing accent returns the identical object. The colour work this needed is the interesting half. Mixing toward white in gamma-encoded sRGB bends hue: a saturated blue lands 7.6 degrees violet of where it started, which is how a clean blue accent produced a lavender selection row. `mixOklab` holds the hue and moves only chroma and lightness. `ensureContrastOklch` adapts a seed for an appearance that cannot carry it by walking lightness rather than blending toward white, which would gut the chroma and wash the brand colour out. `readableOn` picked text colour from a luminance threshold, and got five shipped accents wrong in the direction that matters — white on GitHub's own dark green measured 3.29:1, below AA, where near-black measures 5.50:1. It now measures both candidates and takes the better one.
Every other dot in the set reads a token; unread was a hardcoded `emerald-500`. On a blue theme that left eight green marks down the sidebar fighting the palette around them. It now paints `--ui-success`, a success green rotated part of the way toward the accent along the shortest hue arc. Partway rather than all the way, because landing on the accent would make "finished" and "running" the same colour. The default costs nothing by construction: emerald sits at 162 degrees and GitHub green at 148, so a quarter rotation moves the dot about three degrees. The work only happens when the accent is genuinely far away, which is the case that was clashing.
Translucency was one number serving both appearances and both platforms, resting at zero. A lever that starts at zero is a feature nobody finds, and one number cannot serve four situations: a tint that reads as a whisper over a dark palette is a milky sheet over a light one, and the same numbers that read as frost on macOS vibrancy read as a washed sheet over Windows acrylic, which composites its own tint in DWM before the page is drawn. So the state splits. `mode` stays global — clear versus glass is a choice about the window, not the palette — while the values resolve through a ladder, per key: the appearance you are looking at, then a shared base, then the platform default. Tuning light mode stays in light mode; an untouched dark keeps inheriting. A v1 state lands in base, so a window someone already tuned crosses the upgrade with exactly what was on screen. Main reads the same defaults at window creation, because a window born opaque cannot reliably be swapped to glass afterwards. The chat backdrop goes off by default in the same pass: it was competing with the glass field for the same surface.
Finding a colour by hex is guesswork; finding one by eye needs a picker that does not lie about where you will land. HSV crushes the whole blue family into a narrow band of its hue rail, so dragging "to blue" puts you on pure sRGB blue, which reads violet — every blue that actually looks blue lives in a few degrees you cannot reliably hit there. This one is OKLCH. The hue rail is perceptually even and previews the current colour at every hue rather than showing a generic rainbow, and the field is a canvas drawn per-pixel through the real conversion, so its curved edge is the true sRGB gamut boundary — every pixel is a colour the display can show. Dragging repaints the whole app against the real derivation. It ships off (`defaultEnabled: false`) and holds no persisted state: the override clears on dispose, so turning the plugin off returns every surface to the authored theme rather than stranding a colour with no control to clear it. The retint itself stays in core, where Appearance settings and the command palette can reach it.
The light default carries a single point of fade so the window edge reads as glass rather than as paint. That point followed anyone who dragged the tint to zero, leaving a window that asked to be opaque sitting at 0.9999. Fade now applies only while glass is actually active, not merely selected.
Midnight is monotone in a way none of the other skins are, and it turns out to be a good test of the retint: its ring is `#8b80e8` under a `#ddd6ff` primary — the same violet at a different lightness, not a repeat of one hex. Matching accent slots by exact equality with the primary left that ring behind, so re-seeding produced a half-retinted theme with a purple ring under a teal accent. Slots now join the family by HUE, within a tolerance, and each keeps its own lightness and chroma when it moves. A theme that deliberately runs a deeper ring keeps that relationship instead of being flattened onto one colour. Near-greys are excluded by chroma rather than hue, so mono's neutral ring still stays exactly where its author put it.
… the SDK Two things a genuinely fresh instance surfaced that no existing profile could. The renderer's mode fell back to `light` when nothing was stored, so a dark-mode desktop opened a white window on first launch. Main already defaulted its own themeSource to `system`, so the two disagreed at boot — and once translucency became per-appearance it also handed those users light's much heavier tint, tuned for a bright desktop they don't have. Both the normalizer and the SSR fallback now say `system`; an explicit choice still wins. The accent plugin reached straight into `@/components` and `@/themes`, which the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk` and nothing else, so the app can move its internals without breaking them. The fix is to widen the SDK rather than exempt the plugin — it now exports the OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any plugin can derive a palette instead of hardcoding one.
lisajlau
pushed a commit
that referenced
this pull request
Aug 20, 2026
Two independent bugs let a deleted profile reappear / leave orphaned resources on next launch: 1. hermes_cli/profiles.py's backend-process scanner required argv[0] to resolve to an executable literally named "hermes". Electron's pool-backend spawn resolves the hermes console-script shim's path and execs it via the interpreter directly (python3 /path/to/hermes ...), so argv[0] reports as "python3" and the scanner never matched the running backend -- delete removed the profile's files but left its live backend process running (still bound to a port via uvicorn), which accumulates across repeated delete/recreate cycles. 2. The desktop sidebar's ProfileRail only refreshed its cached profile list once, on mount, so a delete/create/rename from another surface (another window, or the CLI) left a stale ghost entry until something unrelated triggered a refetch. Note: a delete via this window's own Manage-Profiles view already refreshes the shared $profiles atom ProfileRail subscribes to (confirmed by reading refreshProfiles() and handleConfirmDelete()) -- this fix only covers the cross-window/cross- process staleness gap, not a duplicate of the already-merged NousResearch#57329's Manage-Profiles rail-refresh work. Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named console-script shim via argv[1]. Fix 2: refresh the profile list on window focus/visibilitychange, matching the existing pattern used elsewhere in the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx, use-gateway-boot.ts all use the same focus+visibilitychange pattern). ## Related work already on main PR NousResearch#57329 (merged) fixed the *headline* symptom from issue NousResearch#52279 (deleted profile respawns) via a different, non-overlapping mechanism: routing profile-delete through the primary backend instead of spawning a fresh pool backend, plus a separate recreation guard in ensure_hermes_home() (NousResearch#49435, merged) that makes a backend spawned into a deleted profile's directory raise FileNotFoundError instead of silently recreating it. This PR is NOT a duplicate of that fix. Verified: even with both of those merged, a backend process that survives because of gap #1 above still holds a bound port via uvicorn -- it just can no longer resurrect the profile directory. That's real resource-hygiene, not a symptom already covered. Gap NousResearch#2 touches a different file/component (ProfileRail / profile-switcher.tsx) than NousResearch#57329's rail-refresh half (which touched the Manage-Profiles view's own $profiles.ts / index.tsx) and covers a distinct staleness path (cross-window/cross-process, not same-window delete-then-refresh). Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing + regression coverage for the argv[0] python-interpreter detection case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Why
WhatsApp could stay silent with
require_mention, but it could not retain the surrounding group conversation for later questions.Validation