The Maintenance panel's only log poller was an effect keyed on the
action name. The backend spawns each op under one fixed name ('doctor',
'security-audit', 'backup', 'curator-run'), so running the same op a
second time set the name state to the value it already held. React
skipped the effect and no poll started. launch() had already cleared the
status, which hid the log panel and re-enabled every op button while the
new run was still going.
Measured with the real panel against a fake backend that keeps the
fixed-name contract: after a second Run doctor, getActionStatus had been
called once in total (the first run's poll), nothing was rendered and
the button stayed enabled. The status response for the second run was
never fetched, so its rail task was never upserted either.
Key the tail on a fresh object per launch instead of the bare name, so
every launch restarts the poll and cancels the previous one. With the
fix the second run is polled, its output and the running indicator
show, the button is disabled, and the rail task holds the new pid.
readImageForRemoteAttach reused attachment.previewUrl as if it always held
the full-resolution bytes for remote image uploads, but attachImagePath
drops previewUrl the moment a thumbnail exists — the only large data URL
kept afterward is the bounded (<=512px) thumbnailUrl. The cache lookup was
therefore always a miss in normal usage and the fallback disk read masked
it, but the contract was false and any future change that populated
previewUrl with the retained thumbnail would silently upload a downscaled
image with no error (#93324).
Removes the dead cache-reuse path and always reads the image fresh from
disk for remote/cross-filesystem uploads, matching the producer's actual
guarantee.
dashboardFallbackArgs rewrote args.indexOf('serve'), the first token equal
to 'serve'. For a profile literally named 'serve' that is the --profile
value, so the fallback corrupted the profile and left the real serve
subcommand in place for a runtime that cannot parse it. The remote
ownership verifiers had the same collision: a remote dashboard spawned
under a 'serve' profile carried two 'serve' tokens in its cmdline and was
judged foreign, so stale cleanup refused to reap it.
Both now locate the subcommand positionally, skipping the value consumed
by -m/--profile/-p, and the remote verifiers count only non-value 'serve'
tokens.
A disposer with its own bug aborted the bare forEach in both
unloadRuntimePlugin() and failRegistration(), stranding the
loaded.delete() behind it. The registration stayed held, so every
later reload re-ran the same broken disposers and died before the
fresh register() — file edits looked inert until an app restart
(#126338).
Release the record first and run each disposer in its own try/catch;
failRegistration() now reuses unloadRuntimePlugin() so the rollback
path is tolerant the same way.
Client wake capture had no health monitoring: after a platform capture
failure (macOS PLAS IPC delegate error -> StopSourceOnError) getUserMedia
stays resolved but the PCM is dead (ended track, halted callbacks, or
endless zeros). The ear kept showing listening while the gateway detector
could never fire, and wake.feed refusals plus feed RPC errors were
swallowed (no onError wired). Add track-ended/stall/silence watchdogs and
consecutive feed-refusal escalation; wire onError to an honest off state
with the reason and lease release. Closes#119089
Both request paths into a non-active profile - gatewayForProfile (a plain
local profile) and requestGatewayForAgent (a registry route) - dialed
whenever the socket was down, straight past scheduleReconnect's full-jitter
ladder and stable-open rule. openSecondary only coalesces concurrent dials,
so a background poller against a backend that accepts and then drops the
socket redialed once per poll: the reconnect loop and renderer CPU spin in
#121865. On the local path every attempt was two dials, because
sharedPrimaryRoute probes main before the secondary dials.
Keep a per-scope dial-failure record (last failure, streak). Inside a window
that starts at 300ms and doubles to the ladder's 15s cap, background callers
fail fast and leave redialing to the ladder; the throttle sits ahead of the
sharedPrimaryRoute probe. Foreground callers always dial and a first dial is
never blocked. A socket that opens and dies before RECONNECT_STABLE_OPEN_MS
counts as a failure and a bare open does not clear the record, so an
accept-then-close backend keeps escalating.
The record lives in module state keyed by scope, like reauthFailures: a
background request lease disposes its entry when the request fails, so
per-entry history reset on every poll. It clears on a served RPC or a socket
that lived, and on a foreground re-arm, connection removal or full teardown.
Auth rejections are left to reauthFailures.
A turn whose store rows end in a tool round with content='' (no prose row to
fold into it) hydrates as a text-less assistant shell. preserveLocalPendingTurnMessages
paired the settled live bubble with that shell and dropped it, because
localPendingSupersedes only accepts live-tail authoritative rows, deleting the
answer the user just watched stream.
Keep a settled, non-interim local final with answer text over an error-free
committed shell with no answer text. Resume reconciliation keeps the stricter
localPendingSupersedes rule; a committed prose row still folds into the shell and
wins, so no duplicate.
Based on #123065 by @kvnloo.
Fixes#123047
The Wayland launcher (#121377) passes --ozone-platform=wayland on every
local Wayland session. On the NVIDIA proprietary driver (615.x) the GPU
process dies under Wayland ozone with the bundled Chromium, while x11
launches. Default to x11 when /proc/driver/nvidia/version exists and the
user set no platform or x11/wayland hint; explicit choices still win.
Fixes#126013
preview.read / preview.act / terminal.read / window.read / tour are answered
only by the Desktop window that shows the requesting session (#114981). When
no attached window showed it, every window stayed silent and the agent waited
out the full 30-45s bridge deadline, then got the generic "No preview tab is
open, or the read timed out."
A window not hosting the session now declines with JSON-RPC error 4404
instead of staying silent. The gateway counts a decline as that client's vote,
not as the answer: the request stays open for the owner window and settles
only once every attached answering client declined, with a distinct refusal
("No Hermes Desktop window is showing this chat ... Ask the user to open this
chat in the Desktop app, then retry."). A bystander window therefore still
cannot beat the owner (#113348).
The backend advertises the counting via client.capabilities
(declines_not_shown); the shared channel only sends a decline to a backend
that did, so a new app against an older backend keeps today's silence.
Fixes#119333
Generalize the /btw exemption to every registry action that runs beside
the live turn (/btw, /bg, /background) via isSideTaskSlashCommand, keep
the ordinary-message behavior when attachments ride along, and fix the
gateway mock cast so desktop typecheck passes.
Fixes#126188
Co-authored-by: Charan Rathore <charan-rathore@users.noreply.github.com>
Over speakers, the reply bleeds into the mic, trips the renderer's
playback-phase barge monitor, and the transcript was submitted as a user
turn (with the "user interrupted" note). Port the CLI's is_tts_echo rule
(difflib ratio >= 0.6 + transcript-sized sliding window) as a pure TS
helper and apply it in submitCapturedUtterance: a playback-phase capture
matching the reply being spoken is treated as silence — no submit, the
interruption latch is cleared, and the loop resumes listening.
Covers the TTS-echo half of #126708; the client-direct STT hallucination
filter and the Desktop voice.barge_in pref are untouched.
A mid-turn correction sealed the live bubble but left the regenerate's
pendingBranchGroup armed, so the post-correction reply joined the branch
group. The runtime repository parents every group member to the group's
user row, which made that reply a sibling of the sealed partial: the
partial and the correction dropped off the rendered branch, and
applyBranchVisibility then marked the partial hidden in the store.
appendMidTurnUserMessage (shared by the main composer and session tiles)
now ends the branch group at the correction.
Covers the regenerate half of #119015; a plain-turn redirect already kept
the partial on main (steer-arrival-order suite). Related to #119686.
The POSIX managed-update drain only signals the Desktop-owned serve through
a pidfd so the signal is bound to the verified process. Darwin has no
equivalent, so terminateOwnedDashboardForUpdate deliberately refuses there
rather than accept a PID-reuse window. The update lifecycle still cycled
every forward first, hit the refusal, and reported the row as a failed
update ("Update fan-out failed") on every attempt.
Detect a live Desktop-owned Darwin serve right after scope capture, before
any forward or serve is touched, and return a structured refusal with a
skip reason. "Update all instances" now reports that connection as skipped
with a clear per-remote message; macOS remotes with no live Desktop serve
need no drain and still update, and the other rows are unaffected.
Fixes#124617
Durable recovery cleared its journal only when every scope restored, and the
update gate fences dials, edits and removal while a journal record exists. One
scope that never restores therefore wedged the SSH connection forever.
Once the remote install marker is positively clear, recovery now ends when the
correlated remote exit code is 0, or after MAX_MANAGED_SSH_RECOVERY_ATTEMPTS
failed attempts (persisted as `attempts` on the record). The journal record is
cleared, the gate stops fencing, and the unrestored profiles are logged; the
next ordinary dial starts them again. The in-session update path clears the
journal the same way after a proven-successful update.
Fixes#107827
Every mounted UserMessage and assistant row re-ran a full scan of
thread.messages on each streamed chunk (runtimeUserOrdinal,
interAgentSender), so per-chunk cost grew with rows x transcript length.
Build one id->index / id->user-ordinal map per messages array and share
it across rows. At 400 turns this cuts per-chunk time from ~42 ms to
~30 ms in jsdom; 20 turns unchanged.
Covers the per-row transcript-scan part of #126486. Prior art: #69151.
Every SshConnection for one scope/host/identity hashes to the same
ControlPath, and ControlMaster=auto attaches later connections to the
master already listening there. A stale bootstrap attempt (timed out,
superseded, rolled back) that closed late ran `ssh -O exit` on that
socket and tore down the master its successor had attached to, killing
the live backend's forward after "Remote Hermes backend is ready".
Connections now claim their ControlPath from the moment open() starts
dialing and release it on close or failed open; close() runs -O exit
(and the socket-unlink fallback) only when no other connection still
holds the socket. No-mux (Windows) is unchanged.
Covers the stale-attempt teardown half of #97264; the hardcoded 45s
ready timeout is untouched (tracked in #94642 / #94665).
Refs #97264
Drives `openNewSessionTile` through the exact shape "New chat with this bot"
and the Bot Mode tab-strip "+" use — a bots workspace scope with the bot's
own route — and asserts `session.create` carries no `hidden` param.
A contract, not a snapshot: it says a hand-started conversation in a bot's
profile is an ordinary listed session, which is what makes it findable,
resumable, renameable and deletable from the Sessions sidebar and `/resume`.
Red on the unfixed tree (`hidden: true`), green with the blanket flag gone.
`openNewSessionTile` stamped `hidden: true` on every session created while
the workspace was in Bot Mode. That path only ever mints side chats — "New
chat with this bot" and the Bot Mode tab-strip "+" / ⌘T — so each one was
born unlisted and stranded: absent from the Sessions sidebar, skipped by
`/resume`, and unreachable once its tab closed, because the bot row opens the
canonical chat and "Open recent session" reads `last_session`, which never
reports a hidden row. Users had no way to find, rename, or delete a bot
conversation they started by hand.
Only Bot Mode's PLUMBING sessions are meant to be hidden, and each already
mints its own row with the flag set: the canonical Bot Chat in
`hermes-bots/canonical-chat.ts` and group member sessions in
`hermes-bots/group-turns.ts`. The hide sweep agrees — its title allow-list is
deliberately exact so "a user's real conversation inside a bot profile ...
is never touched" (`hermes-bots/session-sweep.ts`) — and so does
`apps/desktop/src/AGENTS.md`: side chats "stay visible in the sidebar".
Drop the blanket flag. The sidebar concern it was reaching for is already
handled where it belongs: `mergeSessionPage` and the refresh keep-list in
`store/session.ts` drop `hidden` rows, so a canonical Bot Chat cannot be
resurrected into the list. `listTileSessionRow` keeps its Bot Mode guard, so
a side chat surfaces on the next refresh once its first turn persists,
exactly like a Sessions-mode ⌘T tab.
Review follow-up on the cross-env removal. The first cut re-tokenized the
inherited NODE_OPTIONS on whitespace (collapsing quoted preload paths such as
`--require "/a b/p.cjs"`) and let a smaller inherited --max-old-space-size
win, which cross-env never allowed. The heap flag is now appended to the
inherited string untouched, last, so 16384 always wins exactly as under
cross-env; the env is built once per source build, and HEAP_FLAG is
module-local.
The builder-script regression test now runs the real `npm run builder` with an
apostrophe path and asserts the wrapper received it verbatim (red on the
cross-env script) instead of matching the script text.
`npm run builder` (the tail of `pack`/`dist` and of `hermes desktop` /
`hermes update` rebuilds) went through `cross-env` to set NODE_OPTIONS for
electron-builder. That broke two ways:
- When the desktop workspace's devDependency bins were not staged (omit=dev
inherited, a half-rolled-back install), every rebuild died with
`cross-env: not found`, and the Python recovery misread the missing
Hermes.exe as a blocked Electron download and retried via the mirror
(#110121).
- cross-env strips every bare `'` from the arguments it forwards, so the
staging override `-c.directories.output=C:\Users\r'y'z\...` reached
electron-builder as `C:\Users\ryz\...` and the pack died with
`EPERM mkdir 'C:\Users\ryz'` (#103010). Platform-independent.
run-electron-builder.mjs already spawns process.execPath for every child
(native-deps staging, packaging-tool preparation, the strict builder, and
electron-builder itself), so it now sets the heap flag on those children
(respecting an inherited --max-old-space-size) and the npm script is plain
`node …`. cross-env stays a devDependency for the dev:* scripts only.
(cherry picked from commit 0c0d5ab02b46a27879ab5def2b170ffeee605269,
re-applied onto the prepared-packaging wrapper)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
A zone whose header strip is hidden (or never rendered) stranded its
pane with no discoverable close: the zone menu lived only on the strip
and the edit veil, so a full-width pane had no right-click path to
Close/Minimize either (#92500).
Serve the same ZoneMenu the strip carries from the BODY: PaneBody
gains a wrap slot that receives its own element, so the ContextMenu
trigger attaches to the body div directly - no extra layout box, the
zone's flex geometry and the hidden-viewport retention are unchanged.
floating-panes keeps its bare PaneBody.
Reworked onto the current renderer from the tree-group ZoneMenu wrap
in #93194. New zones.zoneMenuLabel aria string in all seven locales.
Co-authored-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
Cmd+Shift+L (view.showBrowser) only ever opened the Browser pane -
there was no way to close it from the keyboard, so the hotkey read as
half-dead once the pane was on screen and you reached for the mouse
(#92500).
toggleBrowserTab() asks the layout tree whether the mirrored
preview-tile pane is actually on screen (not dismissed, hidden,
minimized, or parked behind a sibling tab): if not, it opens/re-fronts
the Browser with the page it last showed; if yes, it dismisses just
that pane through the tree's own close path. The TAB survives a
close, so the next toggle restores the page instead of landing on
about:blank - the reveal path already handles un-dismissing.
The command palette entry and the en/ar/de/es/fr/ru/zh labels follow:
'Open browser' becomes 'Toggle browser'.
Reworked from #95826 onto the current preview store (pane id built
from PREVIEW_TILE_PREFIX; dismiss through the tree's dismissTreePane).
Co-authored-by: Jason Pollak <jason@runninwithitmarketing.com>
The titlebar's right-sidebar button and Cmd+J are documented as
POSITIONAL ("shows/hides everything on its physical side of the main
zone"), but the implementation is pane-bound to the files pane. Two
breakages follow: a Browser preview column (whose panes register
placement 'main', so the semantic side derivation skips it) is
invisible to the toggle entirely, and dragging the files pane into the
left stack leaves the toggle pressing a pane that no longer owns the
right side.
Resolve the target from the live tree instead: the rightmost foldable
root-row column (leaf group, no workspace/session-tile surface),
whatever panes live there. Fold/unfold through the tree's own zone
minimize so the column keeps its tab on a persistent rail and
round-trips exactly like the zone chevron. Falls back to the existing
semantic branch when no right column exists, so terminal-on-bottom
layouts keep Cmd+J alive.
Reworked from #92745 onto the current tree store (rootRow is exported
for the walk instead of a local duplicate).
Co-authored-by: Frank S. Tucker III <Treytucker05@users.noreply.github.com>
The in-app Browser can fill the layout with no close control in reach -
no toolbar X, no tab strip when the zone hides it - leaving "ask the
agent to close a panel" as the only way out (#92500).
Put the tab's own Close verb on the browser bar, after the DevTools
glyph: PreviewPane threads its onClose into the bar, so the docked
preview mirror's close (the tab's X verbatim: console cleanup + tab
removal) is reachable from the pane body itself. Embedders that pass no
verb (the popped-out Browser shell) render no dead button.
Reworked onto the current right-rail from the browser-bar close in
#91934 and #93194, whose branches predate the PreviewPane onClose
threading that already landed for error/file bodies.
Co-authored-by: Trounaldo <ryanrtrout@gmail.com>
Co-authored-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
The count now rides the context meter instead of a separate status bar
item: a quiet layers glyph + N after the percentage once the session has
compacted (the same glyph the sidebar puts on compression-continuation
rows), and Compressions: N beside N% Full in the meter's panel. Zero
stays out of the meter so fresh sessions carry no noise; the panel shows
it.
Main removed the intro cinematic (#126139): the gate no longer has a
`cinematic` phase and store/intro-reveal is gone. The funnel stops recording
an `intro` step (dropped from the contract and v3 schema, never shipped) and
the consent strip no longer waits on the intro film.
The M13 fix stopped counting any key a tool panel also asks for, since a
bare PUT /api/env cannot tell connecting a provider from configuring a
tool. Desktop onboarding and model settings now send provider_setup=true
and the server counts those saves; the Keys page and tool panels keep
the exclusion.
m13: the renderer stored and sent raw plugin palette/keybinding ids, rage-click
targets and config paths below user-named containers (providers.<name>.api_key);
the backend collapsed them, but only after truncating the action list to 200.
- recordAction collapses anything outside the built-in ids (KEYBIND_ACTIONS +
KEYBIND_READONLY + the named buttons, minus numbered slots — exactly the
backend's DESKTOP_ACTION_IDS) to `other` before it is counted or used as a
rage-click target.
- recordSettingsSaved sends only keys the config schema publishes (the Settings
page passes its schema: DEFAULT_CONFIG leaves), so nothing below a user-keyed
container leaves or is kept.
- Backend DAILY_ACTION_ROWS_MAX = |DESKTOP_ACTION_IDS| x |vias| (296): every
distinct collapsed (action, via) fits, so none is cut before collapsing.
m14: first-run steps happen before the consent question and collection defaults
off, so every step but consent/first_message was dropped. Chose the in-memory
buffer (smaller than removing the steps from contract, schema and TS): while the
answer is unknown/undecided, recordOnboarding/closeOnboardingStep calls are held
in memory (max 100, never persisted or sent) and replayed on the switch turning
on; a decided "off", a profile switch or quitting discards them.
setDesktopMetricsGate takes `decided` (hook passes consent.decided).
Tests (desktop-metrics.test.ts): recordSettingsSaved callers pass a schema
(contract change). New invariants, RED on base: plugin action id / rage target
stored and sent as `other`; a providers.<name> key is not sent; pre-consent
onboarding held, sent on yes (with closes applied), dropped after a decided no.
Probe (review/desktop/jsrun/.../desktop-metrics.probe.test.ts, raw ids):
before: localStorage actions {"acme-plugin:/home/alice/clients/bigco|palette":1, ...|click:3};
wire rage_click target "acme-plugin:/home/alice/clients/bigco", setting
"providers.acme-corp-internal.api_key"
after: localStorage actions {"other|palette":1,"other|click":3}; wire
[rage_click target "other"] only
M9 + the renderer half of M10, and m15.
M9: the renderer kept one install-wide localStorage record and bound the new
gateway before reading its consent, so a day accumulated under profile A was
flushed to B before B's switch was read (or dropped by B's off), and A's area
latch suppressed B's first use.
- The record is keyed per (connection, profile): `hermes.desktop.metrics.v1:
<FNV hash>` (no profile name in the key). bindDesktopMetrics takes the
profile scope; a different scope forgets in-memory state and nulls the gate,
so nothing is kept or sent until that profile's switch is read.
- The hook pins its requester to the focused (connection, profile) with
requestGatewayForAgent instead of the session-tile router, so the RPCs land
in the profile whose switch gates them. main receives the same scope hash.
M10 (renderer): each window cached the record in module memory and the last
debounced persist() won, so peer windows overwrote each other's counts. Every
change now reads the record fresh and writes it back (no debounce, no cache;
persistDesktopMetricsNow is gone). withState calls are no longer nested
(noteMessageSent / abandoned-step reporting read first, record after), since a
nested write would be overwritten by the outer one. A daily flush is pinned to
the scope and requester it started with. Both windows may still send the same
finished day; the backend's durable latch (earlier commit) records it once.
m15: consent was re-read only on attach or profile switch. The hook re-reads
it on window focus; Settings › Privacy applies its save to the gate when it is
scoped to the focused profile by name, not only when unscoped.
Tests (desktop-metrics.test.ts): `stored()` now finds the per-profile key and
setEnabled is expected with the scope argument (contract change). New
invariants, RED on base: profile switch A→B→A keeps/sends nothing of A under B
and A still reports its day; two module instances (windows) of one profile sum
into one day.
Probe (review/desktop/jsrun/.../desktop-metrics.probe.test.ts, adapted to scopes):
before: B received before its consent read: [["shared_metrics.desktop_daily",{...view.toggleSidebar 4...}]]
areas latch A: [terminal_pane] B: []
two windows stored: {"composer.send|shortcut":4} (W2's session.new lost), W2 daily sends
composer.send 3 + session.new 2
after: B received before its consent read: []; areas latch A: [terminal_pane] B: [terminal_pane]
stored: {"composer.send|shortcut":4,"session.new|shortcut":2}; W1 and W2 send the same
aggregate (the backend latch keeps one)
M11. RendererCrashRecorder held one install-wide `enabled` flag that every window
overwrote with its own profile's switch, so an opted-out window's crash was
written to disk and reported into another profile, and an opted-out window
wiped the opted-in profile's pending crash.
- Consent is kept per webContents id (the IPC sender), with a hash of the
window's focused profile key; set-enabled now carries that key.
- A crash is recorded only when the crashed window's own profile collects;
each pending entry is tagged with the profile hash (file v2: {entries:[{profile,
reason}]}; an untagged v1 file is ignored — whose it was is unknown).
- take() returns only the calling window's profile's reasons; one claim per
profile (the same window may re-claim after its renderer reloaded); ack drops
only the claimed count. Turning a profile off drops only that profile's entries.
- main.ts passes the window's webContents id to recordRendererGone.
Tests: renderer-crash-metrics.test.ts moves to the per-window API (contract
change: every call names its window, set-enabled names the profile); one new
invariant test (opted-out window records nothing, another profile neither
drains nor purges) — the base API cannot express it, so RED is shown by the
reviewer's probe instead.
Probe (review/desktop/jsrun/.../renderer-crash-metrics.probe.test.ts, adapted to
window ids):
before: after opted-out window crash, file exists: true {"v":1,"reasons":["crash"]}
take by window 2: {"reasons":["crash"]}; A crash record after B-window off: false
after: file exists: false; take by window 2: null; A crash record after B-window off: true
CLI setup and the Desktop consent dialog add one 'how Hermes gets used'
line covering the v5 harness, efficiency, engagement, Desktop feature/
dislike and provider-setup counters, and say setting values never leave.
No new UI: counters hang off interactions the app already has.
- feature_use: panes (terminal/file/browser/review), overlays (palette,
model/session pickers, switcher, find), voice, Bot Mode (the `bots`
workspace), skins, projects, full-page routes and each settings view.
- action_use: keybinding dispatch (shortcut), palette items (palette),
titlebar/sidebar tools that carry a keybinding id, composer send/stop,
voice, message copy/retry (click, via a typed const table), and the
native Open Folder menu (menu). Aggregated per day in the app and sent
once per finished day.
- mode_use: Sessions vs Bot Mode from $workspaceMode / the tile's
workspace scope; active time = interaction gaps <= 5 min.
- friction: user toast dismissals by closed notice id, error toasts by
their code-defined summary category, backend drops (settled 3s so a
Restart/switch cancels), long frames while visible, renderer crashes
drained from main.
- dislike: quick close (<5s), cancelled flows, settings saves (keys
only), rage clicks, undo (restored draft, reopen closed tab), shipped
features switched off.
- onboarding: transitions of the existing first-run stores.
All of it follows the focused profile's collection switch: off (or not
yet known) means nothing is kept in localStorage and nothing is sent,
and switching off purges the local record and tells main to drop its
crash file.
A renderer crash takes the renderer (and its backend socket) with it, so
the renderer cannot report it. Main now observes render-process-gone on
live windows (never user close or intentional teardown), persists only
the bucketed reason (crash/oom/killed/other) under userData while the
renderer has reported the user's opt-in, and hands the list to the
reloaded renderer via take/ack IPC, mirroring the pending update-run
record. Turning collection off deletes the pending file immediately.
Desktop love/hate telemetry needs a backend half: six counters
(feature_use, action_use, mode_use, friction, dislike, onboarding) with
closed dimension sets, the v3 JSON schema entries, the public doc rows,
and profile-scoped gateway RPCs the app reports through.
Every value the app sends is collapsed server-side onto the closed set
(unknown -> other); feature use latches per area per UTC day, friction
and dislike are capped per day, the daily report (action counts + mode
time + bot count) latches per day and answers recorded=false on a store
failure so the app keeps the day for a retry. For setting changes the
app sends only the config key: the backend reads the saved value and
compares it to DEFAULT_CONFIG itself, so values never cross the wire;
keys outside DEFAULT_CONFIG read other. All helpers check enabled()
before doing any work.
The v4 startup-latency IPC and packaged-update recorder added ~26 lines to
main.ts (18k lines). desktop-shared-metrics.ts now owns the claim IPC, the
recorder construction and its renderer IPC, and the packaged-only apply
wrapper; main.ts keeps one import, one constructor call and two call sites.
The recorder's behaviour is unchanged (update-metrics.ts untouched).
- m9: shared_metrics.startup_latency had no server-side latch, so any repeat
call added a row. The TUI and Desktop now send an opaque per-launch id
(TUI: one per process; Desktop: minted when the once-per-launch main-process
claim succeeds) and the backend claims (surface, launch_id) once per
process: a reconnect re-sending the same launch counts once, a new Desktop
launch against a long-lived backend still counts. Legacy clients without an
id count once per backend process and surface. The id is only a local latch
key (bounded set, length-capped), never recorded. Only a usable measurement
spends the claim. Contract + apps/shared regenerated.
- m10: relaunch() execs in place, keeping the PID, so `sessions browse` ->
resume counted the picker time as CLI startup. relaunch() stamps its PID in
HERMES_RELAUNCHED_PID right before exec; process-start surfaces skip when it
matches, while children (other PIDs) inheriting the env still count.
- m8 (psutil half): record_process_ready checks the opt-in gate before reading
the process start time (psutil) or starting a thread. The claim is taken
first so a later opt-in never records a stale "startup" mid-session.
Test changes to existing assertions: the TUI/Desktop param assertions now
expect launch_id (a new wire param); the RPC surface test sends distinct
launch ids so its three calls stay three launches under the new latch.
CLI setup and the Desktop consent details now list update results and timing,
crashes, startup/reply speed, messaging-platform health and the coarse machine
facts (RAM range, GPU type, version age), so the opt-in describes everything the
v4 counters record. All Desktop locales updated.
Desktop's packaged updaters (electron-updater, App Installer, Store) never run
`hermes update`, so the receipt cannot count them. Main persists a pending
record before apply (the installer may quit the app before apply returns),
decides a hand-off on the next launch (version changed => success), and the
renderer sends it once the gateway is open; the file is removed only after the
RPC resolved. Checkout hand-offs are excluded: their receipt already counts
them as kind=desktop. The backend buckets raw words into bounded dims.
The backend buckets and records hermes.startup.latency, but only the clients
know when they were launched. The Ink TUI reports Node process uptime at its
first gateway.ready; Desktop reports Electron main-process uptime when the
renderer's gateway first connects. The Desktop latch lives in the main process
because renderer reloads, extra windows and backend reconnects all re-run the
renderer boot. Both calls are fire-and-forget and swallow errors so older
backends without the method are unaffected.
Two product questions shared metrics could not answer: how long each surface
takes from launch to usable (so startup regressions show per release), and how
current, on which channel and on what class of machine installs run.
hermes.startup.latency {surface, latency_bucket} records once per process start:
cli (process creation -> first rendered prompt, or -q dispatch; Kanban workers
excluded), gateway_boot (-> GatewayRunner.start done), serve_boot (-> hermes
serve listening), and tui / desktop_attach reported by the clients through the
new shared_metrics.startup_latency RPC. The clients declare their surface
because a Desktop on a URL/cloud backend has no HERMES_DESKTOP there; env
detection is the fallback for older clients. In-process surfaces measure from
psutil's process create time, the earliest timestamp available, and hand the
runtime start to a daemon thread under the caller's context so no event loop
waits on it. Everything goes through _emit, so disabled profiles record nothing.
The install snapshot gains release_channel, version_age_bucket, behind_bucket,
ram_bucket, gpu_class and local_model_provider_used. All are read offline:
the installed commit's own date, the channel record / packaged channel / checkout
branch (never the remote URL or branch name), and the update check's existing
cache for this exact revision (never a network call). Rows counted before these
fields existed stay valid as a legacy field set.