Both stale-pin detections exempt only '' and 'auto':
- desktop persistentStaleAux banner (model-settings.tsx)
- switch-time stale_aux response (hermes_cli/web_server.py)
'main' is a backend-supported alias (auxiliary_client._normalize_aux_provider)
meaning "follow the active main provider", so aux slots pinned to it can
never be stale. The false positive fires for users following Moonshot's
official Hermes integration guide, which prescribes
auxiliary.vision.provider: main.
Exempt the alias in both places and add a regression test.
The contextSwitching early return in isRouteSessionMismatch sat above the
same-id short-circuit, so a profile or connection switch while the route
already pointed at the selected session reported a mismatch and blanked the
chat to the splash. On main that call returned false.
Move the selected-session check ahead of the contextSwitching guard: when the
selected view already owns the routed conversation there is no prior context
to leak, so nothing needs hiding. The guard still denies only the
transcript-retention fallback, which is the case it was added for.
Adds the exact regression to route-session-state.test.ts.
The node context menu now uses Radix, whose menu items are focusable
`div[role=menuitem]` elements. The window-level Space handler in
star-map.tsx only skipped INPUT/TEXTAREA/BUTTON/contentEditable, so
pressing Space on a focused menu item both activated the item and toggled
playback.
Extract the guard into `shouldIgnorePlaybackHotkey`, which additionally
bails when the event was already `defaultPrevented` or when the target or
active element sits inside a `[role=menu]`, and cover the menuitem case
with a small vitest.
The Star Map right-click menu was a hand-rolled `position: fixed` card
placed at the raw `clientX/clientY`, so a star within ~75px of the bottom
(or ~144px of the right) edge clipped the `Delete memory` / `Archive skill`
row off-window while `Edit …` stayed visible — the destructive action
silently disappeared.
Reuse the shared Radix `DropdownMenu` anchored to a zero-size fixed span at
the click point — the exact pattern `AppContextMenu` already uses — so the
menu gets the same flip/shift collision handling (and `collisionPadding`,
keyboard navigation, Escape/outside-click dismissal) as every other menu in
the app, instead of adding a second bespoke measure-and-clamp path.
`Edit …` keeps the menu open while the node content loads (`onSelect`
`preventDefault`) exactly as before; `openEdit` closes it on success.
Refs #109288. Supersedes the measure+clamp approach of #109301 (credit
@KoNit-K for the diagnosis). #100894 routes the gesture to this menu and is
untouched.
The zh and zh-hant values for settings.customEndpoints.namePlaceholder
were meta-text ('示例代理(占位符)' / '範例代理(預留位置)') — literally
"example proxy (placeholder)". A placeholder should show what the user
would actually type, matching the en locale's concrete example name
('Axet Proxy'). Both scripts now use '我的代理' ("my proxy").
The desktop eslint gate (padding-line-between-statements) flagged the
salvaged test file; a blank line before the `cases` declaration keeps
`npm run check:lint` at zero problems for the touched files.
KoNit-K's two commits stamp an owner on tab-strip `+` drafts at create
time, which clears the banner ON the draft tile. Promoting that draft into
main (⌘W on the workspace tab, or a tab dragged out of main) still raised
"Session controls unavailable": closeSessionTile drops the tile AND evicts
its $sessionStates mirror in one tick, resumeSession then makes the
runtime active before the view republishes, and the composer's control
read lands in that gap — storedSessionIdForRuntimeId had no tile, no
mirror, and so never reached the stored-id hint that was there all along.
Give the translation one more rung: the active runtime maps to the
selected stored id. Only main's own binding qualifies; unrelated runtime
ids stay unknown and keep failing closed.
Live (Electron + mock gateway, bot chat in main, `+` draft, close main's
tab so the draft promotes): main → banner; KoNit-K alone → banner on
promote; with this rung → no banner at any step, send works.
Invariant test red on main, green here.
Tab-strip new tabs omit options.profile; record the draft or active
profile as the tile owner so session.control.read can resolve.
Co-authored-by: Cursor <cursoragent@cursor.com>
Subagent completions already got this: the model receives the full
`[ASYNC DELEGATION …]` text while the CLI/TUI/Desktop paint a one-line
"Subagent Task Completed: <goal>" event. Background-process completions
(`terminal(background=True, notify=True)`) still echoed the entire
`[IMPORTANT: Background process proc_… completed normally (exit code 0).
Command: … Output: …]` block as if the user had typed it.
Generalise the delegation mechanism: `TimelineNotification` (formerly
`SubagentNotification`) carries `display_kind` + `display_text`;
`ProcessNotificationBatch` renders a `process_complete` one with a
`process_completion_display_text` title ("Background Process Finished:
<cmd>", "Background Process Failed (exit 1): <cmd>", "N Background
Processes Finished"). The TUI gateway stamps the same kind/metadata on
the synthesized turn and emits the title on `status.update`; Ink and
Desktop project `process_complete` rows as timeline events (Desktop keeps
the raw output behind the existing expandable async-result row). Model
content is byte-identical to before.
Keep the null-route stub test (the #108369 / #102792 trigger) and the
runtime-id session.control.read parity test; drop the routed-connection
and stub-eviction cases (the routed path already had owner hints on main,
and eviction is an implementation detail of the stub atom).
Tab-strip/project-sidebar '+' with listed:false minted a backend session
but recorded its owner nowhere when the owner route was null (local source
+ named profile): no tile route, no hint, no row. The tile's immediate
session.resume then failed closed on multi-profile installs.
Record an ownership stub in a dedicated off-list atom that rides only the
owner-lookup path: same stamps as an optimistic row, shadowed by real rows,
evicted on list/drop.
An MCP server whose OAuth refresh token died used to get one warning toast
per app session (on the transition into needs-auth) and then nothing: the
server sat parked, silently, until the user happened to open Capabilities →
MCP. A dead token is a standing problem that needs an action, so the health
checker now re-nudges once a day while the server stays broken (persisted
24h snooze per profile+server, same pattern as the update/skew toasts) and
the toast offers the second way out: Disable, which writes enabled: false
through PUT /api/mcp/servers/{name}/enabled. The gateway's config reconcile
(#109906) and the serve backend's reload both follow that edit.
Toasts gain an optional secondaryAction (outline button beside the primary).
The desktop and the web dashboard each carried a private copy of the
cyberpunk / ember / midnight / mono palettes and they had drifted: the
dashboard's cyberpunk canvas was #040608 with a mint #9bffcf accent
while the desktop's was #000a00 with #00ff41, ember and midnight
disagreed on both canvas and accent, mono agreed only by luck.
Move the raw palette table for every built-in preset into
@hermes/shared (`THEME_PRESET_PALETTES`, apps/shared/src/theme-presets.ts)
and make it the single source of truth:
- apps/desktop/src/themes/presets.ts spreads its `colors` / `darkColors`
from the shared table; the OKLCH synthesis, terminal palettes and
typography stay in the desktop. Serialised BUILTIN_THEMES are
byte-identical to before, so the existing `--dt-primary-solid`
parity pins stay green untouched.
- web/src/themes/presets.ts projects each shared preset onto its
3-slot model through one pure function, `webPresetFromShared`
(background <- background, midground <- primary, warmGlow <- the
midground/ring accent), so cyberpunk / ember / midnight / mono now
render the desktop's palette. Web-only presets (default,
default-large, nous-blue, rose) are untouched.
- Invariant test (web): for every preset shared by both surfaces the
dashboard canvas equals the shared background and the projected text
colour keeps >= 3:1 contrast against it. Red on the previous hexes,
green now.
Why: one edit in one place should recolour a preset on every surface;
two hand-maintained tables guarantee the drift the audit found.
The autosave test did `await import('./config-settings')` inside the test
body. That module graph is large: 1.5s cold on an idle machine, and on a
saturated CI runner (import 1682s aggregate across the run) it alone
crossed the 15s testTimeout twice today on PRs that never touched the file.
The assertions themselves take ~100ms. Hoisting the import into a
beforeAll with its own 60s hookTimeout keeps the test's budget for the
behaviour under test; the fault-injection mocks are hoisted vi.mock calls
and still apply.
The 1e9 → '1000M' test row read as a snapshot of a missing rung; the
formatter comment now states the cap (token/cost figures stay well under a
billion; a B suffix would collide with the bytes reading) and the row cites it.
The shared ensureContrast shipped the TUI's fine 0.05×20 ladder, which
changed --dt-primary-solid for 7 of 15 desktop presets (nous #3b6acb →
#3f70d8, cyberpunk #00661a → #008021, slate #505457 → #6f7377) while the PR
body said no preset VALUE changed. The ladder is now the desktop's original
algorithm exactly — pole by luminance < 0.5, accumulating 0.2 steps up to
1.0001, re-mixed from the source colour — with `step` as a parameter. The
only pre-refactor TUI caller (ColorChain.ensureContrast) passes 0.05, so
the terminal palette is byte-identical too.
Test: apps/desktop context.test.tsx iterates every builtin preset × mode,
paints it through ThemeProvider and asserts --dt-primary-solid equals the
value a reference copy of the old desktop algorithm computes. Sabotage
(default step 0.05): 11/30 rows fail. Docs: the SDK table now lists
contrastRatio as `number | null` under sRGB measures, not OKLCH.
The desktop model picker moved from foldIncludes (searchFold: lower-case +
`[-_.]` → space on text AND query) to the shared fuzzyRank, which only
lower-cased. `gpt.4o`, `claude_3` and `qwen3-8` returned zero rows where
main listed gpt-4o / claude-3-opus / qwen3.8-flash, while HighlightMatches
still folded — filter and highlight disagreed. fuzzyScore now folds both
sides with a length-preserving fold, so positions still index the original
target and all three surfaces rank a separator variant identically.
Tests: three separator rows in fuzzy.test.ts and in the desktop picker
test. Sabotage (lower-case only): all six fail.
34 of the 46 `NO_DESKTOP_SURFACE` rows in desktop-slash-commands.ts were a
byte-for-byte copy of `desktop=` on the matching CommandDef in
hermes_cli/commands.py (7 more were aliases of those rows). The live
`commands.catalog` already carries that metadata; the static list was the
offline fallback and would silently drift on the next registry edit.
Now `hermes_cli/commands.py::desktop_surface_registry()` is the one author
of `/name -> desktop` (aliases included). `scripts/dump_desktop_slash_registry.py`
writes it to apps/desktop/src/lib/desktop-slash-registry.json, which the
desktop imports as its offline fallback (`registryUnavailableSpecs`). Five
names the Python registry has never heard of stay in an explicit
`TS_ONLY_NO_DESKTOP_SURFACE` with the reason WHY: `/density /details /logs
/mouse` are Ink-process-local display toggles (handled in
ui-tui/src/app/slash/commands/core.ts; advertised via `_TUI_EXTRA`), and
`/pets` is the plural typo of the desktop's own `/pet` action. `/switch`
was never a block-list row (it is a `/resume` alias) — not a finding.
Cross-language contract: tests/hermes_cli/test_desktop_slash_registry.py
asserts the committed JSON == desktop_surface_registry() and that every
alias carries its canonical value; desktop-slash-commands.test.ts asserts
every dumped row is unavailable/unsuggested offline with the dumped reason
and that the TS-only set is disjoint from the dump. Both sides fail on
drift (sabotage: flipping one `desktop=` in commands.py -> Python test
"stale"; adding `/clear` to the TS-only list -> vitest disjointness fails;
dropping `registryUnavailableSpecs()` -> 4 existing vitest cases fail).
Behavior change: none for users. `/model` (`desktop="hidden"`) keeps its
local picker spec; `hidden` is a popover flag read from the live catalog.
Sites: apps/desktop/src/lib/desktop-slash-commands.ts::NO_DESKTOP_SURFACE
(46 rows) -> hermes_cli/commands.py::desktop_surface_registry (41 rows via
the dump) + TS_ONLY_NO_DESKTOP_SURFACE (5 rows). apps/desktop/src/AGENTS.md
updated.
web/src/lib/slashExec.ts and web/src/components/SlashPopover.tsx had zero
importers since the React composer was replaced by the PTY-embedded TUI
(f49afd3122) — exactly what web/AGENTS.md forbids, now orphaned. Their
parseSlash still carried the `(.*)` newline bug and lacked the `prefill`
variant. Desktop and the TUI each hand-rolled the same slash split and the
same command.dispatch narrowing; the multi-line fix (#41323, #55510) had to
be applied to each copy separately.
Sites:
web/src/lib/slashExec.ts::executeSlash/parseSlash/parseCommandDispatch -> deleted
web/src/components/SlashPopover.tsx::SlashPopover -> deleted
apps/desktop/src/lib/chat-runtime.ts::parseSlashCommand -> apps/shared/src/slash.ts::parseSlashCommand
apps/desktop/src/lib/chat-runtime.ts::parseCommandDispatch -> apps/shared/src/slash.ts::parseCommandDispatch
apps/desktop/src/lib/chat-runtime.ts::SLASH_COMMAND_RE -> apps/shared/src/slash.ts::SLASH_COMMAND_RE
apps/desktop/src/app/types.ts::*CommandDispatchResponse (5 interfaces) -> apps/shared/src/slash.ts
ui-tui/src/domain/slash.ts::parseSlashCommand/looksLikeSlashCommand -> apps/shared/src/slash.ts
ui-tui/src/lib/rpc.ts::asCommandDispatch -> apps/shared/src/slash.ts::parseCommandDispatch
ui-tui/src/gatewayTypes.ts::CommandDispatchResponse -> apps/shared/src/slash.ts
9 desktop importers + 3 TUI importers repointed.
Behavior change: desktop `parseSlashCommand` now lower-cases the command
name like the TUI, backend `resolve_command` and `slash.exec` already do
(`/Help` resolved before via the case-insensitive backend; local desktop
action lookups were case-sensitive). TUI's parsed result no longer carries
the redundant `cmd` echo (no consumer read it).
Tests: apps/shared/src/slash.test.ts (parseSlashCommand multi-line /
newline-boundary / degenerate cases; parseCommandDispatch every variant +
malformed rejection). Sabotage: restoring `(.*)` in SLASH_PARTS_RE fails
2 tests; restored -> 7 pass. Desktop chat-runtime.test.ts and TUI
asCommandDispatch.test.ts cases moved here; slashParity.test.ts repointed.
ui-tui/src/lib/color.ts called itself "the twin of the desktop app's
src/themes/color.ts" and the two had already drifted: the desktop measured
readableOn but used a coarse 0.2x5 ensureContrast ladder and returned 0 for
unparseable luminance; the TUI had the fine 0.05x20 ladder and null-for-garbage
but a luminance>0.5 threshold readableOn. Both now import the primitives from
apps/shared/src/color.ts (`@hermes/shared/color`, also exported from the root
index); each surface keeps only what is specific to it. No palette / preset /
skin VALUE changes anywhere — only math.
Sites (path::symbol → canonical):
apps/desktop/src/themes/color.ts::hexToRgb → @hermes/shared/color::parseColor (deleted)
apps/desktop/src/themes/color.ts::rgbToHex → @hermes/shared/color::toHex (deleted)
apps/desktop/src/themes/color.ts::mix → @hermes/shared/color::mix
apps/desktop/src/themes/color.ts::relativeLuminance → @hermes/shared/color::relativeLuminance
apps/desktop/src/themes/color.ts::contrastRatio → @hermes/shared/color::contrastRatio
apps/desktop/src/themes/color.ts::readableOn → @hermes/shared/color::readableOn (desktop wrapper readableInk pins ['#161616','#ffffff'])
apps/desktop/src/themes/color.ts::ensureContrast → @hermes/shared/color::ensureContrast
ui-tui/src/lib/color.ts::{Rgb,parseColor,toHex,mix,relativeLuminance,contrastRatio,readableOn,ensureContrast,lighten,darken}
→ @hermes/shared/color (same names)
Stays desktop-only (apps/desktop/src/themes/color.ts): luminance, normalizeHex, readableInk, OKLCH set
(hexToOklch, oklchToHex, oklchToSrgb255, maxChroma, hueDelta, harmonize, mixOklab, withHue, ensureContrastOklch).
Stays TUI-only (ui-tui/src/lib/color.ts): liftForContrast, grayOf, desaturate, toHsl, fromHsl, retone,
boostSaturation, color()/ColorChain.
Importers repointed (17): apps/desktop/src/{sdk/index.ts, themes/context.tsx, themes/retint.ts,
themes/retint.test.ts, themes/skin.ts, themes/vscode.ts, themes/vscode.test.ts};
ui-tui/src/{theme.ts, sdk/index.ts, sdk/apps/weather.tsx, app/createGatewayEventHandler.ts,
components/agentsPanel.tsx, components/branding.tsx, components/loaders.tsx,
components/overlayPrimitives.tsx, lib/color.ts, lib/color.test.ts}.
Wiring: apps/shared/package.json exports './color'; apps/shared/src/index.ts re-exports;
apps/desktop/tsconfig.json paths + vite.config.ts alias for '@hermes/shared/color'
(ui-tui resolves the subpath via the workspace package exports, like './billing').
Behavior change (1): relativeLuminance / contrastRatio return null for unparseable
input on the desktop too (previously 0, which made garbage measure like pure
black). Desktop SDK export `contrastRatio` therefore widens to `number | null`.
Only ensureContrastOklch relied on the number: it now treats null as "already
passing / can't measure" and returns the input unchanged. Every other desktop
caller passes 6-digit hex.
Behavior change (2): readableOn MEASURES both candidate inks and returns the one
with the higher contrast ratio (desktop semantics; the threshold version got
mid-lightness accents wrong: white on #4f9e5e is 3.29:1 vs near-black 5.50:1).
Signature is readableOn(bg, inks = ['#000000', '#ffffff']); the desktop passes
its own pair via `readableInk` so desktop output is byte-identical. The TUI
switches from the luminance>0.5 threshold to measurement: over the 185 distinct
hexes in ui-tui/src/theme.ts (DARK/LIGHT seeds + built palettes) and
hermes_cli/skin_engine.py, 56 flip from '#ffffff' to '#000000' — all
mid-lightness accents (L 0.18–0.49, e.g. #cd7f32, #4caf50, #ef5350, #ffa726,
#4dabf7) where black measures 4.6–10.8:1 against white's 1.9–4.5:1. Note the
TUI never called readableOn directly; it only reaches ensureContrast's pole
choice (below), and ensureContrast is only reachable via the color() chain and
the theme.ts re-export (no production caller today).
Behavior change (3): ensureContrast steps 0.05 x 20 from the ORIGINAL color toward
the measured readableOn pole (TUI semantics). The desktop previously stepped
0.2 x 5 toward a threshold-chosen pole, so desktop-derived accents that needed a
lift (skin/VS Code imports whose accent fails 4.5:1 on the sidebar, and
--dt-primary-solid) may now land up to 0.15 closer to their original hue —
they stop at the first passing rung. Palette VALUES are unchanged; only
synthesized colors move.
Also: parseColor accepts #rgb shorthand where desktop hexToRgb rejected it —
strictly more permissive; the only desktop path fed raw user hex is
normalizeHex, which already expands shorthand itself.
Tests: apps/shared/src/color.test.ts (moved TUI parse/mix/contrast cases +
two invariants):
- "readableOn(%s) returns the ink with the higher measured contrast" — computes
contrastRatio for each candidate in the test and asserts the returned ink is
the max (a contract, not a hardcoded hex) over #4f9e5e (both ink pairs),
#cba6f7, #ffffff, #101014.
Sabotage: reverted readableOn to the luminance threshold → 3 red
(#4f9e5e x2, #cba6f7); restored → green.
- "ensureContrast(%s on %s) clears %s" — 5 failing pairs end ≥ min; plus
"leaves passing and unparseable colors byte-identical".
Sabotage: truncated the ladder to 3 rungs → 5 red; restored → green.
ui-tui/src/lib/color.test.ts keeps only the color() chain case.
Validation:
apps/shared: npx tsc -p . --noEmit (0) && npx vitest run → 3 files, 30 tests passed; npm run lint clean
apps/desktop: npx tsc -p . --noEmit (0); npx vitest run --project ui → 798/800 files, 7563/7572 tests;
the 9 failures (src/app/messaging/index.test.tsx x8 12s-timeouts, src/lib/markdown-blocks.test.ts
property fuzz 36s) are load-induced flakes under the full parallel run: both files pass in
isolation on this branch (16/16) and on origin/main; neither imports color math. npm run lint 0 errors
ui-tui: npm run build:ink; npx tsc -p . --noEmit (0) && npx vitest run → 168 files, 1764 tests passed; npm run lint 0 errors
git diff --check clean; no new gitignored .d.ts.
Handoff: desktop vs web preset palettes diverge for the four shared ids
(web presets carry a 3-slot palette {background, midground, foreground(alpha 0)}
+ warmGlow, not the desktop's 24-slot set, so only the comparable slots are
listed; web `foreground` is #ffffff alpha 0 on all four — a glow/overlay
slot, not text ink). Design call for Teknium; nothing changed here.
preset slot desktop web
cyberpunk background #000a00 #040608
cyberpunk accent #00ff41 (primary/ring/mid) #9bffcf (midground)
cyberpunk foreground #00ff41 #ffffff (alpha 0)
ember background #160800 #1a0a06
ember accent #d97316 (ring/midground) #ffd8b0 (midground = desktop fg/primary)
ember foreground #ffd8b0 #ffffff (alpha 0)
midnight background #08081c #0a0a1f
midnight accent #8b80e8 (ring/midground) #d4c8ff (midground)
midnight foreground #ddd6ff #ffffff (alpha 0)
mono background #0e0e0e #0e0e0e (match)
mono accent #9a9a9a (ring/midground) #eaeaea (midground = desktop fg/primary)
mono foreground #eaeaea #ffffff (alpha 0)
Desktop and web each re-implemented the same locale plumbing: the
TranslationOverride<T> partial-catalog type, isRecord (four copies across
the two apps), mergeTranslations, the RTL_LOCALES={'ar'} set with the
documentElement.lang/dir effect, and the endonym table for the language
picker (6 entries on desktop, 17 on web, overlapping and hand-synced).
The generic parts now live once in apps/shared/src/i18n.ts (exported from
the root index and the `@hermes/shared/i18n` subpath). It is generic over
the catalog type — no Translations, no `en` — so translation catalogs stay
per-app (content decision, deliberately not merged here).
Sites (path::symbol → canonical):
apps/desktop/src/i18n/define-locale.ts::TranslationOverride, isRecord,
mergeTranslations → @hermes/shared/i18n; defineLocale is a one-liner
web/src/i18n/define-locale.ts::TranslationOverride, isRecord,
mergeTranslations → @hermes/shared/i18n; defineLocale is a one-liner
apps/desktop/src/i18n/runtime.ts::isRecord → shared isRecord
apps/desktop/src/i18n/context.tsx::isRecord, RTL_LOCALES,
applyDocumentLocale → shared isRecord / applyDocumentLocale
web/src/i18n/context.tsx::RTL_LOCALES + inline lang/dir effect
→ shared applyDocumentLocale
web/src/i18n/context.tsx::LOCALE_META literal (17 names)
→ derived from shared LOCALE_ENDONYMS (same exported shape)
apps/desktop/src/i18n/languages.ts::LOCALE_OPTIONS.name (6 names)
→ LOCALE_ENDONYMS.<id>; englishName/configValue columns stay
The six desktop endonyms were byte-identical to web's before the move.
Tests: apps/shared/src/i18n.test.ts — mergeTranslations keeps untouched
sibling keys under a nested partial override and replaces functions/arrays
wholesale without mutating the base; RTL_LOCALES ⊆ keys(LOCALE_ENDONYMS);
applyDocumentLocale is a no-op without a document. The existing desktop
context.test.tsx RTL/lang assertions keep covering the effect.
Behavior change: none.
Three TS surfaces each carried their own ANSI stripper with different
coverage. The TUI's (OSC, DCS/SOS/PM/APC strings, complete and truncated
CSI, multi-byte non-CSI ESC sequences, stray ESC, C0 controls) is now the
single implementation at apps/shared/src/ansi.ts, exported from the root
index and the new `@hermes/shared/ansi` subpath (ui-tui has no DOM lib, so
it imports the subpath like it does for billing/skin).
Sites (path::symbol → canonical):
ui-tui/src/lib/text.ts::stripAnsi, sanitizeAnsiForRender, hasAnsi
→ moved to apps/shared/src/ansi.ts (text.ts now imports stripAnsi
from '@hermes/shared/ansi' for its own trail helpers)
ui-tui: 13 importers repointed from '../lib/text.js' to
'@hermes/shared/ansi' (createGatewayEventHandler.ts,
components/messageLine.tsx, 11 __tests__ files)
apps/desktop/src/lib/ansi.ts::stripAnsi (2 regexes) → deleted;
parseAnsi/ansiColorClass/hasAnsiCodes stay (styled-segment parser)
apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
→ imports stripAnsi from '@hermes/shared/ansi'
apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
private SGR-only stripAnsi → deleted; imports the shared one
Tests: the TUI 'ANSI sanitizers' cases move from
ui-tui/src/__tests__/text.test.ts to apps/shared/src/ansi.test.ts, plus
one invariant: an OSC-8 hyperlink + DCS string + SGR + partial CSI tail
strips to exactly the visible text with no ESC/BEL left.
Behavior change: desktop chat system messages (use-prompt-actions) and
inline-diff chrome (stripInlineDiffChrome) now also lose OSC hyperlink
payloads, DCS strings, truncated CSI tails and C0 control bytes that the
weaker regexes let through. TUI behavior is unchanged.
Three hand-rolled compact-number formatters and two mirrored copies of the
reasoning-effort value set collapse into apps/shared/src/format.ts and
apps/shared/src/reasoning-effort.ts, exported from the package root and as
the subpaths `@hermes/shared/format` / `@hermes/shared/reasoning-effort`
(the TUI compiles with lib ES2023 and imports subpaths only). Surfaces keep
their own label maps and UI helpers. No re-export shims remain.
Convention for compactNumber (desktop's implementation, moved verbatim):
lowercase 'k', uppercase 'M', promotion-guarded thresholds (>= 999.5 -> k,
>= 999_950 -> M) so rounding can never print "1000k", trailing ".0"
stripped, non-finite / <= 0 -> "0".
Sites (path::symbol -> canonical):
apps/desktop/src/lib/format.ts::compactNumber -> apps/shared/src/format.ts::compactNumber (moved; file deleted)
web/src/lib/format.ts::formatTokenCount -> deleted
ui-tui/src/lib/text.ts::fmtK -> deleted (text.ts's own callers use compactNumber)
apps/desktop/src/app/agents/index.tsx -> @hermes/shared
apps/desktop/src/app/chat/sidebar/chrome.tsx -> @hermes/shared
apps/desktop/src/app/chat/sidebar/session-row.tsx -> @hermes/shared
apps/desktop/src/app/command-center/index.tsx -> @hermes/shared
apps/desktop/src/app/shell/context-usage-panel.tsx -> @hermes/shared
apps/desktop/src/app/shell/titlebar-controls.tsx -> @hermes/shared
apps/desktop/src/app/skills/index.tsx -> @hermes/shared
apps/desktop/src/app/skills/mcp-tab.tsx -> @hermes/shared
apps/desktop/src/components/ui/tab-dropdown.tsx -> @hermes/shared
apps/desktop/src/lib/statusbar.tsx -> @hermes/shared
apps/desktop/src/sdk/index.ts::compactNumber -> re-exported from @hermes/shared (plugin SDK surface unchanged)
apps/desktop/src/plugins/kanban/{board,drawer}.tsx -> unchanged (import via @hermes/plugin-sdk)
web/src/components/ModelInfoCard.tsx::formatTokenCount -> @hermes/shared::compactNumber
web/src/pages/ModelsPage.tsx::formatTokenCount -> @hermes/shared::compactNumber
ui-tui/src/components/appChrome.tsx::fmtK -> @hermes/shared/format::compactNumber
ui-tui/src/components/thinking.tsx::fmtK -> @hermes/shared/format::compactNumber
ui-tui/src/app/slash/commands/session.ts::fmtK -> @hermes/shared/format::compactNumber
ui-tui/src/__tests__/text.test.ts::fmtK suite -> apps/shared/src/format.test.ts (table incl. promotion guard)
apps/desktop/src/lib/reasoning-effort.ts::REASONING_EFFORTS/REASONING_EFFORT_VALUES/
DEFAULT_REASONING_EFFORT/ReasoningEffort/isReasoningEffort -> apps/shared/src/reasoning-effort.ts
(SHORT_LABELS, reasoningEffortLabel, isThinkingEnabled, resolveReasoningEffort stay local)
apps/desktop/src/app/settings/constants.ts -> @hermes/shared
apps/desktop/src/app/settings/model-settings.tsx -> @hermes/shared
apps/desktop/src/app/shell/model-catalog-menu.tsx -> @hermes/shared (+ local reasoningEffortLabel)
apps/desktop/src/app/shell/model-edit-submenu.tsx -> @hermes/shared (+ local UI helpers)
apps/desktop/src/app/shell/model-menu-panel.tsx -> @hermes/shared
apps/desktop/src/lib/model-status-label.ts -> @hermes/shared (+ local reasoningEffortLabel)
apps/desktop/src/sdk/index.ts -> value set re-exported from @hermes/shared; label helper stays from '@/lib/reasoning-effort'
apps/desktop/src/lib/reasoning-effort.test.ts -> value-set + isReasoningEffort cases moved to apps/shared/src/reasoning-effort.test.ts
web/src/lib/reasoning-effort.ts::EFFORT_OPTIONS -> labels mapped over shared REASONING_EFFORT_VALUES (same order: none, then 7 levels)
web/src/lib/reasoning-effort.ts::VALID_EFFORTS -> Set(REASONING_EFFORT_VALUES); normalizeEffort falls back to DEFAULT_REASONING_EFFORT
Semantics kept: web `none` is selectable; desktop `none` resolves to ''
(thinking off); desktop isReasoningEffort still trims + lowercases.
Behavior change:
- web: token counts on the Models page and ModelInfoCard now print a
lowercase 'k' and are promotion-guarded: 128_000 "128K" -> "128k",
999_999 "1000.0K" -> "1M", 1_500 "1.5K" -> "1.5k". 'M' is unchanged.
- TUI: fmtK used Intl compact notation; compactNumber differs only in
suffix case and the guard: 1_000_000 "1m" -> "1M", and billions no
longer get a 'b' suffix (1_000_000_000 "1b" -> "1000M"). Sub-million
values are identical ("999", "1k", "1.5k"). Non-positive values now
print "0" instead of "-1k".
- desktop: none (its formatter moved verbatim).
Tests: apps/shared/src/format.test.ts::"compactNumber" (table incl.
999_999 -> "1M", 999_949 -> "999.9k"; fails when the promotion guard is
removed) and apps/shared/src/reasoning-effort.test.ts::"reasoning-effort"
(no duplicate values, `none` is the only non-level, default is a member;
fails on a duplicated level or a `none`-accepting isReasoningEffort).
Three byte-identical (modulo prettier and a "keep in sync" header comment)
copies of model-search-text.ts and two of fuzzy.ts collapse into one copy
each under apps/shared/src, exported from the package root and as the
subpaths `@hermes/shared/fuzzy` / `@hermes/shared/model-search-text` (the
TUI compiles with lib ES2023 and imports subpaths, never the DOM-typed
root). The vitest suites move with the code; no re-export shims remain.
Sites (path::symbol -> canonical):
ui-tui/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank -> apps/shared/src/fuzzy.ts (moved)
web/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank -> deleted
ui-tui/src/lib/model-search-text.ts::modelSearchText -> apps/shared/src/model-search-text.ts (moved)
web/src/lib/model-search-text.ts::modelSearchText -> deleted
apps/desktop/src/lib/model-search-text.ts::modelSearchText -> deleted
ui-tui/src/lib/fuzzy.test.ts -> apps/shared/src/fuzzy.test.ts (moved)
ui-tui/src/lib/model-search-text.test.ts -> apps/shared/src/model-search-text.test.ts (moved)
ui-tui/src/components/modelPicker.tsx::fuzzyRank, modelSearchText -> @hermes/shared/fuzzy, @hermes/shared/model-search-text
web/src/components/ModelPickerDialog.tsx::fuzzyRank, modelSearchText -> @hermes/shared
web/src/lib/model-picker-filter.ts::fuzzyScoreMulti -> @hermes/shared
apps/desktop/src/components/model-picker.tsx::modelSearchText -> @hermes/shared (+ fuzzyRank, see below)
The header comment now names only the cross-language twin
(hermes_cli/model_search.py) as the thing to keep in sync.
Behavior change (desktop only): the desktop model picker used to filter
model rows with `foldIncludes` substring matching and keep the curated
order; it now ranks them with the same `fuzzyRank(models, query,
modelSearchText)` the web and TUI pickers use. What a user sees
differently while typing a query:
- subsequence queries match: "g4o" now finds "gpt-4o" (previously only
a literal substring such as "gpt-4" or "4o" matched);
- the best match floats to the top instead of rows staying in curated
order (exact > prefix > word-boundary > contiguous > scattered);
- a query that matches the provider name/slug still shows that
provider's full curated list in order, exactly as before;
- an empty query still shows the curated list verbatim.
The in-row highlight is unchanged (substring emphasis via HighlightMatches),
so a fuzzy-only hit renders without emphasis rather than mis-highlighting.
Tests: apps/desktop/src/components/model-picker.test.tsx::"orders model
rows exactly as the shared fuzzyRank does" asserts the rendered row order
equals the shared fuzzyRank order for the same inputs (fails on both the
old substring filter and a reversed ranking).
The composer showed "<model> · Med" in one truncating pill and the only way
to change the effort was to open the model menu, find the active model's
row, and hover it for the per-row options submenu. Users read the pill as
"this model is medium only" and never found the submenu.
- New `ReasoningPill` next to the model pill: shows the active model's live
effort (session value, else the profile default) and opens the same
Thinking / Fast / Effort rows the catalog submenu offers, for the active
model only. Hidden when the catalog reports `reasoning: false`; stays
while capabilities are unknown so it never flickers during the fetch.
Folds away with the model pill in the compact composer stages.
- `useModelMenuController` (shell sibling) now owns the session write /
preset / optimistic-store / rollback logic that lived inside
`ModelMenuPanel`; the model menu and the new `ReasoningMenuPanel` share it
so an edit from either surface is one code path. Tiles get their own
pill bound to their SessionView, primary or tile — never the globals.
- `ModelOptionsContent` (the submenu body) is exported container-free so
the pill's top-level menu renders it without a Radix Sub wrapper.
- The model pill drops the effort suffix (`formatModelPillLabel`: name +
Fast); `formatModelStatusLabel` had no other caller and is removed.
- `currentModelCapabilities()` in lib/model-options resolves the active
pick's caps through `catalogProviderMatches` (aliases, custom slugs).
Live (headless Electron + worktree `hermes serve`, CDP): before — one
pill "Deepseek V4 Flash · Low", no effort control; after — "Deepseek V4
Flash" + "Low" pill; pick High → `config.get reasoning` on the live
session returns high; a `reasoning:false` cap unmounts the pill; the
catalog row submenu still writes through and the pill mirrors it.
Credit: the dedicated-pill direction was proposed independently in
composer selector on current main with the shared-controller shape.
Re-verified against the tui_gateway emitters:
- SubagentEventPayload.cost_usd / .iteration: not in
tool_progress.py::_SUBAGENT_FIELDS, never emitted → removed; the TUI's
turnController no longer copies them (its SubagentProgress keeps the
fields for spawn-history persistence).
- SubagentEventPayload.child_session_id: emitted (in _SUBAGENT_FIELDS, read
by agent_callbacks.py::_mirror_subagent_to_child) but untyped → added.
- ToolCompletePayload.error: _on_tool_complete never sets it → removed;
the TUI's completeTool drops its dead `error` parameter and renders the
trail line as non-error (which is what it always did on the wire).
- ToolStartPayload.todos: not on the wire either, but the TUI handler and
its fixtures exercise recordTodos from tool.start; kept with a comment
saying so rather than churning the handler.
- MessageCompletePayload.failure_reason: prompt_turn.py passes
result.get("failure_reason") through → `string | null`.
handleFrame guarded JSON.parse but then read `frame.id` on whatever came
back, so a stdout line of `null`/`42`/`"str"` threw a TypeError out of the
readline handler — an uncaughtException in the Ink process, where main's
TUI had caught and logged it. Non-object frames now return null (the owner
logs a protocol error, as for non-JSON).
The shared heartbeat counted ANY inbound frame as liveness, silently
dropping the TUI's original contract (fail on an unanswered gateway.ping):
a backend whose request loop is wedged but still streams deltas never
tripped the deadline. `heartbeatLiveness` now selects the contract:
'response' (default, TUI) — only a pong or a response to our own request
resets the deadline; 'any-inbound' — the desktop/web WebSocket client's
original behaviour, which JsonRpcGatewayClient passes explicitly so that
surface is unchanged. The dead 'error' branch comment in connect()'s
onClose is corrected to describe the onSocketClose-intercept case it
actually serves.
Tests: it.each over 'null'/'42'/'"str"'/'true' asserts no throw and null;
'response' mode: pongs and request responses keep it alive, streaming
deltas with unanswered pings fire onHeartbeatFailure. Sabotage (remove the
object check + count any inbound): 5 tests fail with the original TypeError.
Four backoff formulas (ui-tui 1000/30s, desktop 300/15s jittered, web events
1000/30s, web PTY inline 250/3s cap 5 — untested) collapse into
apps/shared/src/reconnect-backoff.ts::reconnectBackoffDelayMs(attempt,
{baseDelayMs, capMs, jitter}). Every caller keeps its own parameters
(table in the PR body); the PTY ladder gains a test.
web/src/components/ChatSidebar.tsx hand-rolled a third WebSocket frame
dispatcher (`new WebSocket` + JSON.parse + `frame.method === "event"` switch
+ a private RpcEnvelope re-declaring shared JsonRpcFrame) for /api/events.
That socket now goes through EventsFeedClient, a notification-only subclass
of the shared JsonRpcGatewayClient (replay off, heartbeat off, connect
timeout covering ticket minting); the effect keeps only the retry ladder and
the banner. Both sidebar clients are now created once per component instead
of per `version` bump, so the shared client's seq watermarks survive a drop
and its `session.events.since` gap replay can actually fire for web
(previously the client was rebuilt on every reconnect and replay never ran).
Behavior change: web sidecar reconnects reuse the same JsonRpcGatewayClient
(gap replay now runs); the events feed's handshake `error`+`close` pair is one
`closed` transition (one retry timer, as before); no parameter of any
backoff ladder changed.
Two independent JSON-RPC client cores existed for one backend: apps/shared's
JsonRpcGatewayClient (desktop, web) and ui-tui/src/gatewayClient.ts, which
re-implemented request ids, the pending map with timeouts, response->error
mapping, event decoding and the gateway.ping heartbeat (~200 LOC, drifted).
Split the transport-agnostic half out of the shared client into
JsonRpcRequestChannel (apps/shared/src/json-rpc-channel.ts): the owner binds a
JsonRpcTransport { send(text) } per connection generation and feeds inbound
text through handleFrame(). JsonRpcGatewayClient keeps only the WebSocket
lifecycle, seq replay and the typed event hub on top of it; the Ink TUI keeps
only its two transports (spawned child stdio, attached socket) and its
mount-order event buffering, and delegates everything else.
Behavior change:
- TUI RPC errors now carry the JSON-RPC `code` / `data` (JsonRpcGatewayError)
instead of a bare Error(message); the TUI's timeout text is now the shared
"request timed out after Ns: <method>" (was "timeout: <method>", matched by
no caller) and callers may pass a per-call timeout.
- TUI heartbeat liveness counts any inbound frame (shared semantics) rather
than tracking one in-flight ping id; the interval/deadline are unchanged
and pings no longer carry the unread `last_activity_ms` param.
- Desktop isMissingRpcMethod reads the -32601 code first and only regexes the
message for code-less (IPC-flattened) errors, so a tool result that merely
mentions "unknown method" no longer reads as a capability verdict.
- Shared connect() now settles on a `close` during the handshake (auth-gate
4401/4403) instead of waiting out the 15s connect timeout, and
invalidate()/close() drop the socket generation before calling close() so a
synchronous close event cannot run the closed-path twice.
Three TypeScript clients each declared their own copy of the tui_gateway wire
types and had drifted apart: apps/shared had a partial GatewayEventName union
with a `(string & {})` escape hatch, ui-tui/gatewayTypes.ts a 150-line
discriminated union, and apps/desktop an `RpcEvent<T>` that was field-for-field
the shared GatewayEvent with `type: string`. None matched the emitter:
message.complete lacked warning/status/error/recoverable/error_surface,
tool.start/tool.complete lacked args/result, SessionResumeResponse lacked
session_key/messages_omitted/hydrating/auto_continue/todo_state, three
different ModelOptionProvider shapes disagreed on fields, and all three unions
handled a `tool.progress` event that no Python emitter has ever produced.
Now:
* `apps/shared/src/gateway-events.ts` is the single home: payload interfaces
typed from the Python emitters (file::symbol cited per interface),
`BackendGatewayEventMap` (89 backend names) + `ClientLocalGatewayEventMap`
(5 TUI-synthetic transport events, clearly marked, excluded from the
contract) merged into `GatewayEventMap`; `GatewayEvent<K>` is discriminated
on `type` with `seq` typed. RPC shapes shared by 2+ surfaces live beside it
(ModelOptionProvider = union of every field hermes_cli/inventory.py sets,
incl. pricing_pending/free_tier_pending; SessionResumeResponse<Info>;
SessionListItem with resolved_id; Usage).
* `JsonRpcGatewayClient.on<K>` is keyed by event name; the gateway.ready
heartbeat/replay_epoch and per-frame `seq` reads are typed instead of cast.
* ui-tui and apps/desktop import the shared names; their local duplicates are
deleted (no re-export shims — importers are repointed; the desktop plugin
SDK barrel keeps its public `RpcEvent` name as an alias of GatewayEvent).
web/src repoints ModelOptionProvider/ModelOptionsResponse.
* `tool.progress` handling is removed from the TUI handler/turnController,
desktop event sets/tools handler, shared union, tests, and two docs
(`grep '"tool.progress"' tui_gateway/` = 0 hits; the `display.tool_progress`
config mode is unrelated and untouched).
* `message.complete.warning` (history-commit note from
prompt_turn.py::_complete_turn_payload) is typed and surfaced on both
surfaces through their existing notice paths (TUI pushActivity 'warn',
desktop notify kind 'warning').
Contract: `apps/shared/src/gateway-events.json` is the sorted list of
backend-emitted names. `tests/tui_gateway/test_gateway_event_contract.py`
collects names from the Python emitter side (emit-helper literals, the
`.request → .expire` table, change-watcher table, child delta mirror,
subagent relay, desktop_ui tool emitters, gateway.ready/setup.ready/
browser-controller frames) and asserts emitted == JSON in both directions.
`apps/shared/src/gateway-events.test.ts` asserts BACKEND_EVENT_NAMES (which
the map type is `satisfies`-checked against) == JSON. Sabotage-verified: a
fake JSON name fails both tests; a fake TS name fails tsc + vitest; a fake
Python `_emit("...")` fails pytest.
Three CI-load flakes from the same class — a fixed per-test timeout billed
for one-time module-transform/env-init cost:
- apps/desktop messaging/index.test.tsx: `await import('./index')` ran inside
renderMessaging(), so the FIRST test paid the whole MessagingView transform.
On loaded runners that alone blew the 15s testTimeout and cascade-failed all
subsequent tests in the file (unmounted DOM). Red on main runs 34599517793,
34600757569, 34601269252 (green file takes 15.7s on a green main run —
already over the first test's budget when billed there). Import moved to
module scope, where vitest bills it to collection.
- apps/desktop skills/index.test.tsx: same pattern, 9 call sites; the file ran
18.6s on a green main run. Deduplicated to one module-scope import (the
existing 60s describe-timeout stays for the legitimately slow tests).
- web SessionsPage.test.tsx: the web vitest project still ran on vitest's 5s
default while its per-row routing test legitimately takes 3.6-4.6s on GREEN
runs; run 34600757569 tipped it to 5079ms. Gave web/vitest.config.ts the
same 15s testTimeout the desktop project already carries, with the same
rationale comment.
Validation: both desktop files 5x consecutive green + green pinned to 1 CPU
core (worst-case contention); SessionsPage 3x green; full desktop ui project
(801 files / 7622 tests) green; tsc + eslint clean on touched files.
Port from block/buzz#7336: a playback rate picked in any transcript
video's native controls persists as a device-level preference, so a
viewer who watches at 2x doesn't re-select it for every clip. New
players (and other open windows, via the persistentAtom storage sync)
start at the saved rate; out-of-range or malformed stored values fall
back to 1x, and returning to 1x removes the stored key.
Adapted from Buzz's hand-rolled localStorage module + custom player to
our persistentAtom store and the single <video> render site in
markdown-text.tsx (MediaAttachment), wrapped as TranscriptVideo.
A user who picked `deepseek-v4.1-flash` on their own custom endpoint kept
landing on `deepseek-v4-flash-0731`. Three sites each "helped" by diffing
the pick against a catalog and moving it:
- hermes_cli/models_validate.py: the shared catalog matcher auto-corrected
any id within difflib ratio 0.9 of a listed one (`corrected_model`), and
model_switch applied it. Version bumps, dated snapshots and qualifiers
all sit inside 0.9 of a sibling, so a newer release the listing lacked
was swapped for the older one under the user's label. The matcher now
does exact membership -> suggestion text only; the id goes to the wire
verbatim and a genuine typo is refused with the listed siblings named.
Every branch that carried the correction (live listing, static catalog,
curated fallback, MiniMax, Anthropic, custom, OpenRouter preset base)
loses it in one place.
- hermes_cli/model_switch.py: a `providers.<key>` endpoint reached by its
bare key (the slug Desktop picker rows carry) validated as a built-in
and hit the hard-rejecting live-listing branch; the same endpoint as
`custom:<key>` soft-accepted. Both spellings now validate as the user's
custom endpoint.
- apps/desktop: `manualPickRemoved` (composer reseed) and
`reconcileSelectionAfterCatalogRefresh` (Refresh Models) retargeted a
sticky pick to the profile default / the row's first model whenever the
provider row did not list it. Rows are hints (discovered, curated,
capped); the gateway's switch result is the only authority on a pick.
Both helpers are removed; the pick stays put.
Tests: change-detectors pinning the swap are rewritten as invariants
(never `corrected_model`; unlisted id on a user endpoint is kept and
warned; typo is refused with a suggestion); proven red on origin/main.
Under gateway.multiplex_profiles a secondary's api_server and webhook are never built as
adapters (run_adapters skips SHARED_LISTENER_MIRROR_PLATFORMS: the default's listener answers
/p/<profile>/...). The multiplexer record therefore has no `<profile>:api_server` entry,
profile_platforms_from_multiplexer() returned {} for them and both /api/messaging/platforms
and /api/status?profile= fell through to `pending_restart`: the Desktop Messaging card and
Command Center said "Restart needed" forever for a platform that was answering.
- gateway.status.shared_listener_mirror_platforms projects the default's LIVE api_server /
webhook entry onto every served secondary with `ingress_url` = `<listener>/p/<profile>/v1`
(`.../webhooks/<route>`); a dead default listener is not mirrored. The api_server / webhook
adapters stamp the listener they actually bound (`listener_base`) on connect so the URL is
the real one, not a config guess. `hermes status` lists those URLs beside the other
shared-ingress platforms.
- /api/status?profile= reports `gateway_shared_with` (every profile the multiplexer carries)
when the served rung answered; null for a standalone gateway.
- Desktop: the messaging card shows the URL line; "Restart gateway" from a served profile
(statusbar menu, Cmd+K, messaging/webhooks banners, Command Center) confirms "Restart the
shared gateway? All bots on this device reconnect: default, alpha, beta" (Restart all /
Cancel) and toasts "Shared gateway restarted (3 bots)". Standalone keeps the silent path.
- Dashboard: same confirm + toast on the System page and the sidebar restart; the 409 from
start/stop on a served profile renders as an inline notice instead of a raw error toast.
A `gateway.multiplex_profiles` gateway enumerated `profiles/` once at boot, so a profile
created afterwards (CLI, dashboard, Desktop, TUI) was never served until `hermes gateway
restart`; Desktop and the dashboard gave no reminder, so a new profile's bot simply never
connected.
The served set is now reconciled at runtime (`gateway/run_profile_reconcile.py`):
- `hermes_cli/profiles.py` create/delete ping the multiplexer over its control socket
(new `rescan-profiles` verb); a supervised watcher rescans every 30s as the safety net.
- A new profile gets its adapters under its own runtime scope from its config/.env
(`_start_one_profile_adapters`, same duplicate-credential guard as boot, now seeded
with the LIVE secondaries' claims), `served_profiles` in gateway_state.json is
updated, MCP discovery + log routing run for it. Other profiles' adapters are never
touched.
- A served profile whose config.yaml/.env changed is re-scanned so a token added after
create builds the adapter; already-live/queued platforms are skipped (no second poller).
- A deleted profile (tombstone) has its reconnects cancelled, adapters torn down,
pairing/busy bookkeeping and cached agents dropped, and this process's SQLite /
memory-store handles released so the deleter's rmtree succeeds.
- The in-process cron ticker takes a live enumerator so new profiles' jobs fire.
- PUT /api/messaging/platforms/<id>?profile=X returns `hot_served` when a live
multiplexer rebuilt X's adapters; Desktop/dashboard skip the restart banner then.
- `hermes profile create` confirms hot-serve; the restart reminder stays for a gateway
that did not pick the profile up (older build / signal failed).
`isToolEnabled` used `include?.length`, so `tools.include: []` (the block-all
whitelist the runtime honours) rendered every tool as enabled, and toggling the
last include entry off deleted the key, silently flipping the server back to
"all tools". Keep the empty list.
Salvaged from #52874 (desktop portion) by @Bartok9. Part of #12865.
The picked fix inlined the platform ternary inside terminalShellEnv(), which
reads process.platform and can only be exercised by booting Electron. Lift it
into a pure terminalLcCtype(env, platform) that takes the platform as data
(root AGENTS.md: never fake the host OS) and pin the contract with one vitest:
Linux reuses LANG, falls back to C.UTF-8, respects an explicit LC_CTYPE; macOS
keeps the bare "UTF-8" it accepts. Red on origin/main (helper absent), green here.
Electron sends a local sub-profile's REST to its pooled `hermes --profile X serve` without
?profile=; inside that process the unscoped branches never reached the multiplexer rung, so a
profile served by the default multiplexer read as 'Messaging gateway stopped' on the system and
messaging pages, start/stop spawned a child that exited 78 while the UI reported success, and
restart ran `gateway restart` under X's HOME (same exit 78). Remote-backend topology was already
correct because its requests carry ?profile=.
Unscoped liveness/status/messaging now take the multiplexer rung for the process's own home;
lifecycle verbs resolve the own profile, refuse start/stop with 409 and restart the multiplexer via
-p default; Electron routes POST /api/gateway/{restart,start,stop} through the primary with
?profile= so the action lives on the backend the status poll asks and outside the pooled
backend's shutdown SIGTERM.
latestChatActions rebuilds the ChatView handler bag field by field, so an
optional handler added to ChatActions but not to the adapter is silently
dropped before it reaches ChatView. Live CDP probe on a built Desktop: the
wiring controller had onAttachPastedText, ChatView received undefined, and
a 4,500-char paste stayed inline. onAttachPrCommentUrl and onSteerHidden
(already on main) were dropped the same way on the main chat surface; the
session-tile path passes them directly and was unaffected.
Forward all three via latestOptional and pin the class with one invariant
test: every handler present on the actions bag is present on the adapted
bag (red on the previous adapter).