write-build-stamp.mjs rewrites the stamp's `builtAt` clock on every build, so the
whole-file hash moved under a receipt that had just recorded it. Two builds
overlapping on one tree failed with "Build inputs changed during compilation" for
a difference the build machinery itself made, and the caller turned that into
SystemExit(1).
Hash the stamp's provenance identity instead, key-sorted so a cosmetic
re-serialisation is not a change. The clock is excluded from that comparison but
recorded as `stampClock` — the value the bake actually embedded, captured from
the same read that produces __HERMES_INSTALL_STAMP__ — because packaging copies
the live stamp into the bundle after the receipt is written. Re-reading the stamp
at record time would certify a clock the output never contained, and
productCurrent would then report a bundle current whose packaged clock disagrees
with its baked main, which is exactly what detectBundleSwap must never do.
recordProduct still compares the prepared inputs, so a clock-only restamp cannot
throw. productCurrent additionally requires the recorded clock to match, so a
restamp between bake and packaging is a rebuild rather than a silent divergence.
A receipt predating the field compares by hash alone until the next build
rewrites it; the release packaging path never consults productCurrent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
write-build-stamp.mjs rewrites `builtAt` on every build, and build.mjs runs it
before compilation, so the desktop build rewrote a file its own receipt hashed
as a prepared input. Two concurrent `hermes desktop` builds therefore made
recordProduct() fail with "Build inputs changed during compilation" on a change
the build itself caused, and the caller turned that into SystemExit(1).
Hash the stamp's provenance identity instead of its raw bytes. builtAt is the
only field that moves between two stamps (the tree documents it as
"differs on every rebuild"), and it is a build clock, not provenance: the baked
output changes when commit/payload/tag change, never when time passes. A
non-JSON stamp still falls back to the whole-file hash, so content keeps
deciding freshness and only the clock is ignored.
Unlike b3618bda35, the stamp cannot simply leave the hash set:
bundleElectronMain bakes its bytes into electron-main.mjs, and
bundle-swap.test.ts pins "same commit rebuilt (builtAt moved)" as a real swap,
so freezing the clock would break a committed test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The freshness regression wrote assets/.localized into a hashed web input
dir the fixture never created, so the assertion exercised a
missing->directory transition of the assets input rather than the
junk-in-an-existing-dir case it meant to cover: treeHash goes from
'assets\0missing\0' to 'assets\0', the hash flips, and productCurrent
returns false. Seed assets/logo.svg before buildWeb so assets is an
established hashed dir at baseline; the later .localized is then skipped
by macMetadata and the hash stays byte-identical, keeping assets coverage
in the junk-metadata check.
Reported by @kokhlo.
main removed pruneComposerPopoutZones from @/store/composer-popout (the
popout zones were reworked); dead-session drafts are pruned through
composer.ts's stashedDraftScopes/clearSessionDraft instead. The import was
unused and broke the desktop typecheck.
Pins, composer drafts, and queued prompts persist session ids in
localStorage and re-assert/probe them at boot. Sessions removed from
state.db (retention purge, manual delete) leave dead ids behind:
nothing ever drops them, so every boot re-requests the dead ids and the
gateway answers 404 "Session not found" once per stale reference,
forever.
Add a dead-session prune sweep that runs after the first sidebar list
payload (and re-runs, debounced, on later list changes). A stored id is
only declared dead after a by-id probe 404s on every known profile — a
404 is profile-scoped, so a single miss never prunes anything, and
non-404 probe failures (gateway mid-restart, network) defer the id
instead of dropping it. Dead pins are forgotten from pin-sync
bookkeeping before unpinning so the reconcile does not re-PATCH the
dead id (which would recreate the 404).
Fixes#79001
preprocessMarkdown and normalizeFilePreviewMath told code from prose with
CODE_FENCE_SPLIT_RE, which ends a fenced block at the first ``` or ~~~
anywhere, mid-line included, and whatever the opening marker was. The
rest of the listing then ran through the prose rewrites: citation
stripping turned arr[0] into arr, and $5 became \$5. EMPTY_FENCE_BLOCK_RE
matched an inner closing fence followed by the outer one, so a markdown
example fenced with ```` or ~~~ lost both closers, and the rest of the
message rendered inside the code block.
Both now use one line-based splitter with the CommonMark fence rule that
normalizeFenceBlocks already applies: a fence opens on its own line, and
only a line holding the same character, at least as long, closes it.
_dispatch_skill built a single-skill invocation from (name, arg) with no
stacked handling, so on Desktop/TUI '/skill-a /skill-b do XYZ' left every
extra /skill token in the prompt as plain instruction text — unlike the
CLI (cli.py) and messaging-gateway (gateway/run_inbound.py) paths that
route through split_stacked_skill_commands + build_stacked_skill_invocation_message.
After the `key in cmds` gate, the extra leading skill tokens (up to 5)
are split off and one stacked invocation message loads every skill over
the remaining instruction, with a loading notice and the display
projection the UIs render. A non-skill or repeated token stops the stack
and stays in the instruction, exactly like the CLI/gateway paths.
Adapted from #74876 into the current _dispatch_skill body
(suppress/_tools_mod/_session_home_scope shape preserved).
Fixes#74705
Co-authored-by: Alexey <56967387+lifeFedorovAlexey@users.noreply.github.com>
A backend without the batch wire parks one single-shape gateway request
per question while the tool args carry the canonical one-entry questions
array. The batch card has no qids to answer with, so it sat a
permanently-disabled '0 of 1 answered' preview until the live request
timed out unanswered. Route that skew to the live single card, whose own
question-text match still stands down on disagreement.
Closes#123126
ar.ts had grown to 3,770 lines (2K-law violation, #79963). Split the
Arabic catalog into topic siblings and recompose it in ar.ts via
defineLocale():
ar.ts -> ar_artifacts.ts (artifacts, artifactCard, artifactPreview, preview)
ar_assistant.ts (assistant)
ar_boot.ts (boot, remoteDisplayBanner, updates, guidedGreeting, install, onboarding)
ar_capabilities.ts (catalog, skills, agents)
ar_chat.ts (composer, statusStack, prompts, desktop, tips)
ar_chrome.ts (externalOpenFailed, fileMenu, titlebar, keybinds, sidebar, shell, rightSidebar, zones, contextMenu)
ar_command_center.ts (commandCenter, messaging, profiles, cron)
ar_common.ts (sharedMetrics, common, ui)
ar_connectors.ts (sessionImport)
ar_diagnostics.ts (sendDiagnostics, notifications, errors)
ar_settings.ts (language, settings, modelAssignment, modelPicker, modelVisibility, interfaceMode)
Pure move: every member block moved byte-verbatim; the composed catalog
is value-identical to HEAD's ar.ts (4,935 leaf keys). Members added on
main since the original proposal were assigned to their topic shard.
Fixes#79963
Part of #78647
Co-authored-by: Finn763 <148605355+Finn763@users.noreply.github.com>
Main now derives $focusedStoredSessionId from $focusedTreePaneId, which keeps
the last chat while the sessions list holds focus. Drop this branch's second
derivation and widen that rule instead: any non-chat zone (Files, Terminal,
preview tabs) keeps the remembered chat, so the Files rail no longer jumps to
the primary's project when you click into it.
$focusedStoredSessionId now derives from $activeTreeGroup + $layoutTree, so
focusing a tile in tests must set the main zone's active pane, not only the
session-focus pane atom.
When switching between multiple open project tabs, the primary workspace
could temporarily report an empty cwd before workspaceCwdOwner caught up.
Fall back to the confirmed session cwd from $sessions / $focusedSessionState
so the right sidebar immediately resolves the workspace directory.
- Bind $repoChangeByPath to $repoStatusByCwd so file status decorations
persist independently of active session ownership or detached chats.
- Add directory-level untracked inheritance to repoChangeKindForPath.
- Register right-sidebar workspace cwd in registerRepoStatusCwd and trigger
refreshRepoStatus on sidebar tree refresh.
When switching between session tabs/tiles in the desktop app, the right sidebar file tree retained the primary workspace cwd ($selectedStoredSessionId) instead of updating to the focused session tile's cwd.
Bind RightSidebarPane to $focusedStoredSessionId and $focusedSessionState so the file tree updates immediately upon tab/tile interaction.
Tray.destroy() leaves the StatusNotifierItem interface exported, so a later
new Tray() in the same process cannot re-export and the panel icon dies.
Park the instance until quit or host loss instead of destroying it on disable.
Address review feedback from @kvnloo:
1. The client-direct exact-phrase hallucination check stripped '.'/'!'
anywhere in the transcript, while the relay only strips trailing
('cleaned.rstrip(".!")' in tools/voice_mode_transcript.py). 'thank. you'
was dropped client-direct but kept relayed. Now a trailing strip
(/[.!]+$/), with a regression pinning internal punctuation as a real
turn on both paths.
2. Turning voice.barge_in off after the monitor armed did not disarm it:
ensureBargeMonitor() gates on $bargeInEnabled only at creation, so a
live config refresh saying barge-in is off left the armed monitor
interrupting on speech. An atom subscription now stops + clears the
live monitor on true->false (a mid-flight capture is dropped and the
loop resumes normal listening). Focused mid-turn toggle test added.
tsc -p found request.id used as string after a truthiness guard; narrow to
typeof string and capture before the async closure. Cast the script-runner
stub in the test to the runner type.
After pop-out the live webview lives in the Browser window while the chat
window still owns the active-session gate, so drive_preview/read_preview
answered NOTHING_OPEN/empty from the chat window. Forward both over the
shared window relay (IPC in the app, BroadcastChannel as a dev fallback)
and answer with the pop-out's result; a silent pop-out falls back to the
legacy local error. Both pop-out legs now ride one relay surface.
Adapted from #101530 by FalconOrtiz (rebased onto the server-requests
split; transport moved from BroadcastChannel-only to the IPC relay so it
works in packaged file:// windows).
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A model-switch marker is persisted as role=user on purpose (#48338), so
the next real user row forms a user;user pair that repair_message_sequence
Pass 2 merges into the earlier (marker) row — keeping its display_kind and
dropping the plain row's _row_id from the repaired view. Every rewind /
prompt.submit addressing surface indexes non-display user turns, so the
merged pair became unaddressable and the client's next submit with
truncate_before_row_id failed closed with the input silently dropped
(#94486).
Keep the merged pair addressable: drop the display classification and
carry the plain row's durable id. display_kind never reaches providers
(stripped from every outgoing copy), so the wire payload is unchanged.
Fixes#94486
An interactive chat on an undecided profile now stops on the one-time
offer; the shared e2e home writer records an answer like a real user
who already decided, so PTY/console drivers reach the composer.
One answer per profile, asked once, with the Desktop strip's three equal
choices (Send to Nous / Local only / No thanks) now offered by:
- `hermes setup`: at the end of every flow (Quick, Full, Blank Slate,
Portal, --quick), via the setup-completed record they all funnel into.
- `hermes` / `hermes --tui`: once before an interactive chat on a profile
that never answered. Skipped for -q, piped/JSON output, spawned actions
(HERMES_NONINTERACTIVE), Desktop-hosted panes and managed installs.
- Web dashboard: a banner for the profile being managed, backed by
GET/PUT /api/shared-metrics/consent.
"No thanks" is the terminal default so Enter never opts anyone in; Esc and
the banner's X leave the question open.
Also fixes the answer that could not stick: a CLI "no" equals the shipped
defaults, so save_config stripped it and every surface (Desktop strip
included) kept asking. The answer is now written to config.yaml directly,
and `decided` has one definition shared by the RPC, the CLI and the router.
DELETE /api/sessions/empty called delete_empty_sessions() without a
sessions_dir, so the rows were removed but their files in sessions/ stayed
on disk. Pass the profile's sessions dir, the same way the single delete
route does.
Fluid Voice (and dictation tools like it) pauses other audio over the
macOS media session to hear the microphone, then inserts the transcript
into the focused field — a guarded paste in Chromium/Electron. No
media-session resume signal ever follows, which is why preserving the
stream alone (the reporter's own experiment) still left radio paused.
Track the generation that last reached 'playing' (playGeneration); an
external pause keeps the element intact (status 'paused'), and a paste
into an editable field resumes it via a plain element.play(). The
plugin's own Pause stays destructive (toggle() from 'live' runs stop(),
clearing the element), so a paste never resumes an intentionally paused
stream and Play reconnects from scratch. Never resumed on window focus.
Credit: Kokhlo's PR #108115 contributed the non-destructive external
pause and the survival test.
Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
Browser/media controls pause the element outside our buttons. The old handler
tore down the stream (cleared src, invoked load(), removed the element, and
advanced the generation) before macOS Fluid Voice or any external media
controller could resume it.
The internal Pause control already increments the generation first, so its
resulting media pause is safely ignored by current(). The external-event
branch can therefore be non-destructive: set status to 'paused' instead of
calling stop(). The 'playing' event already transitions the state back to
'live', so resume is possible.
Add a focused lifecycle test: external pause changes status to 'paused',
element and src survive, and a synthetic 'playing' event brings the stream
back to 'live'.
test_used_local_skill_still_produces_learning_node was reported as failing
under scripts/run_tests.sh (env -i) but passing under bare pytest, which read
as ambient-env leakage. Reproduction on both the reviewed head and the current
one shows the suite green under the scrubbed runner; the flake the reviewer hit
was the 'external' source loop-variable leak (fixed in 71485f5763), whose
manifestation depends on rglob scan order — env -i changes TMPDIR, which
changes the tmpdir layout, which changes the order the mount and the local
skill are yielded in. That env-dependence was coincidental, not causal.
Pin the hostile order directly: patch Path.rglob so the external mount's
SKILL.md always scans before the local skill, and assert the local skill still
classifies 'agent' (red at the reviewed head, green now). Also drop the
no-op monkeypatch of hermes_constants._hermes_home_cache — no such attribute
exists, so it reset nothing and only implied a cache reset that never happened.
Skills mounted from skills.external_dirs are externally authored, but every
provenance surface flattened them into 'agent': the usage report, the
dashboard's set-based twin, the desktop catalog/detail UI, and the learning
journey graph (where a used external mount rendered as a learned skill).
- tools/skill_usage: provenance() gains an 'external' tier (external-only:
a local copy of the same name keeps winning, mirroring is_agent_created's
external exclusion and _external_skill_names() scans external dirs once
for the router's set-based twin.
- web_routers/skills: /api/skills classifies hub > bundled > external > agent.
- learning_graph: skills whose path resolves under an external dir are tagged
source='external' and are never learning milestones — a mount is configured,
not learned, even when used.
- desktop: SkillInfo.provenance gains 'external'; the catalog labels mounts
'external' and the detail pane keeps them editable in place. Origin only,
never mutability: read-only enforcement for external mounts was deliberately
rejected by maintainers (commit 8c8fc6c1ec / PR #17512 — a refusal just
breeds silent local duplicates). Provenance decisions extracted into
skill-provenance.ts pure helpers (testable without source-reading tests).
- curator: usage report/filter knows the 'external' origin.
The desktop edit logic in this commit is the natural reading of 8c8fc6c1ec
(external dirs exist so the user can point Hermes at a vault they curate
elsewhere); if maintainers prefer the read-only half as well, it slots in
behind isEditableProvenance() in one place.
EOF
)
Part of #79958
Splits the Traditional Chinese override table (4419 lines on main) into the
same 11 topic files as the source PR, leaving zh-hant.ts as the composing
defineLocale file (177 lines) with the export name zhHant unchanged:
- zh-hant_artifacts.ts (artifacts, artifactCard, artifactPreview, preview)
- zh-hant_assistant.ts (assistant)
- zh-hant_boot.ts (boot, remoteDisplayBanner, updates, guidedGreeting, install, onboarding)
- zh-hant_capabilities.ts (skills, starmap, agents)
- zh-hant_chat.ts (composer, statusStack, prompts, desktop, tips)
- zh-hant_chrome.ts (fileMenu, titlebar, sidebar, shell, rightSidebar, zones, contextMenu)
- zh-hant_command_center.ts (commandCenter, messaging, profiles, cron)
- zh-hant_common.ts (common, billingBlock, ui)
- zh-hant_connectors.ts (sessionImport)
- zh-hant_diagnostics.ts (notifications, sendDiagnostics, errors)
- zh-hant_settings.ts (language, settings, modelPicker, modelVisibility)
Rebuilt against current main: main added six top-level keys since the source
PR branched (externalOpenFailed, sharedMetrics, intro, catalog, modelAssignment,
interfaceMode). They sit outside every topic boundary of the split scheme, so
they stay inline in the composing file at their original positions; the 41
mapped keys moved to the same topics with the same filenames as the source PR.
zh-hant is a sparse override: siblings carry satisfies Pick<TranslationOverrides,...>
and the composition keeps the original top-level key order. No re-export shim.
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Unstacked editor sat in a 1fr grid column with flex-1 + an 8rem min-width floor, so the contenteditable collapsed to the measured 129pt sliver while queue controls crowded the row. Fill the grid track with w-full min-w-0 instead.
stripGeneratedImageEchoes stripped EVERY markdown image and #media: link
from a final message whenever an image_generate tool result was present
in the same turn's parts. A completed response that carried a MEDIA:
directive for a different file lost its attachment link entirely, and a
message whose only content was such a directive rendered blank after the
empty text part was dropped by dedupeGeneratedImageEchoesInParts
(#80621).
Scope the strip to echoes of the actual generated source:
- markdown images are removed only when the src matches a source,
with a lookahead so a longer path (a.png.bak) is not eaten by a
prefix source (a.png)
- #media: links are removed only when the encoded path matches a
source, anchored to the closing paren so prefix paths survive
- bare-path stripping no longer matches inside markdown link
destinations, so [label](/tmp/a.png) is not corrupted to [label]()
Also wrap parseMarkdownIntoBlocksCached in a lexer-throw fallback so a
single pathological message cannot unwind through MessageRenderBoundary
to the root boundary and blank the whole transcript.
Closes#80621
Rebased onto current main: the strip keeps main's whitespace semantics — main deliberately stopped normalizing whole-document whitespace through media extraction (3e1b7af21b), so the same-file echo test now expects the two-space gap the removed image leaves instead of the old collapsed form. The scoped strip itself is unchanged.