4b351c751a made `main` resolve straight to the git branch without reading
releases/channels/main.json (a WAF 403 on that never-published record stopped
updates, #128295). The Desktop source hand-off test still served that record
and asserted apply() fetched it twice, so JS & TS checks went red on every
main push and PR since.
The fixture no longer serves main.json and the test asserts re-resolving main
makes zero requests -- a regression back to reading the record now 404s and
fails the same assertion.
The repo code-health ratchet blocked the push on two counts:
- runEdit had cyclomatic complexity 31 against a target of 20.
- index.test.tsx grew past its main-branch cap.
The pure steps move to rewind.ts:
- planEditAfterConfirm: plan, confirm, session-ownership check and
re-aim.
- retryKeepsDeepConfirm: the stale-retry consent rule.
The hook gains retryStaleEdit, which holds the existing #82462 retry
unchanged apart from the consent rule, and rollBackEdit.
isCompressedAwayError becomes module-level. runEdit is now at CC 20.
The usePromptActions test harness moves to prompt-actions-harness.tsx.
The #133716 hook tests move to index.deep-cut.test.tsx, plus a new test
for a later user turn arriving during the dialog, which goes red with
the "moved" outcome removed. index.test.tsx drops below its main-branch
size. No behaviour change otherwise.
Sol r4 findings:
- P1: the 4018 stale-target edit retry carried the user's deep-cut
confirm into a refreshed transcript that had gained later user turns,
so the server gate was skipped for turns nobody confirmed. The retry
now sends the confirm only when the refreshed transcript has the same
number of later user turns. Otherwise it goes unconfirmed, and a
server 4033 asks again.
- P2: the first deep-edit confirm did not re-check the session after
the dialog. A switch left the busy flags on the new session while the
edit went to the old one. The edit is now dropped when the session
(or the tile's runtime) changed.
Delta review 2 Lows:
- The internal answeredFor parameter rode on the public onReload/onEdit
callbacks, and assistant-ui calls onReload(parentId, config), so it
received the config object on every reload. The 4033 re-run now goes
through private runReload/runEdit, and the public callbacks take only
their own arguments.
- A deep edit re-aimed after the confirm accepted a later user turn that
landed during the dialog, which archived a turn the user never
answered for. revalidateEditPlan now refuses when the count of later
user turns changed. Regenerate already asked again in this case.
Sol r3: the re-run after an accepted 4033 read the active session
again. If the user had switched sessions while the dialog was open, a
parentId=null regenerate truncated and resubmitted the other session's
last turn without asking. The answer now belongs to the session that
was refused: the re-run happens only if the session (the tile's
runtime) is still the same. Applies to primary and tile, regenerate and
edit.
Delta review findings:
- Medium: a confirmed deep edit during a live turn was silently
dropped, because the stale check compared the transcript array by
identity and streaming replaces it on every delta. The edit now
re-plans against the current transcript (revalidateEditPlan). It
proceeds when the edited turn keeps its address and the re-planned
index is applied. If the turn moved, it stops with an "Edit failed"
notice instead of returning silently. Applies to the primary chat
and tiles.
- Low: a live carrier with no row id of its own never counted its own
user turn, so a two-turn cut could pass without 4033. It now counts
(fail closed).
Sol r2 findings on bc1394c357:
- P1: the 4033 fallback dialog skipped the stale-transcript check.
Accepting retried the captured plan even if the transcript changed
while the dialog was open, and declining rolled back to the
pre-submit snapshot over newer messages.
- P2: a slow answer let the optimistic busy arm expire, and the retry
never re-armed it.
A refusal now rolls back at once (the refused submit wrote nothing).
If the user confirms, regenerate or edit re-runs from the top: the
session is re-read, the busy and stale guards apply again, and the
optimistic state is re-armed. The forced deep cut covers only the
transcript the user answered for, so a change during the dialog asks
again. submitWithDeepTruncateConfirm is removed.
Lead fold from the independent review (efficiency Medium; reuse,
quality, thermo and 2c Lows):
- Stale plan: the confirm dialog is a wait as long as the user takes,
so a turn that started or output that landed meanwhile made the plan
and its rollback snapshot stale. Regenerate now returns without
submitting when the session went busy or its transcript changed
during the confirm. Edit, which interrupts a live turn on purpose,
returns only when the transcript changed under a confirmed deep cut.
- One helper: planConfirmedReload and planConfirmedEdit share
confirmIfDeepCut instead of two copies of the check, and the confirm
thunk is built once per callback.
- Naming: deepReloadConfirmRequest is renamed deepCutConfirmRequest,
since edit uses it too, and the 4033 code is a named constant.
- Tests: tile hook tests cover the stale-confirm drop and an
unpredicted 4033 asking once and resubmitting confirmed.
Review findings on #133748 (teknium1 Medium + Low, kvnloo concurring):
- Edit sent confirm_deep_truncate on every submit, so the server gate
never applied to Desktop edits. planConfirmedEdit mirrors
planConfirmedReload: an edit that would archive later user turns asks
the same destructive confirm first. A tail edit or failed-turn edit
asks nothing, and a decline sends nothing. The stale-target retry
re-addresses the same turn and reuses the user's answer.
- The client and server count later user turns differently, so an
under-asking client turned a deep cut into an opaque "Regenerate
failed". submitWithDeepTruncateConfirm handles the server's 4033
refusal by asking once and retrying confirmed. A declined confirm
rolls back silently instead of showing an error toast.
Both apply to the primary chat and session tiles.
c1077745cc made an omitted confirmDeepTruncate mean not confirmed, so the explicit true at the edit and tile restore-retry submits is load-bearing; dropping either would turn an older-turn edit or restore into a silent 4033. Assert the flag on the existing edit and tile stale-target restore tests.
Gate C18 r1 quality [Low]: runRewindSubmit defaulted confirmDeepTruncate
to true while truncateSubmitParams defaulted it to false, so regenerate
had to write `?? false` and restore/edit opted into a destructive server
bypass by omitting a trailing argument. An omitted flag now means not
confirmed; restore/edit and their stale-target retries pass true
explicitly, and ReloadPlan.confirmDeepTruncate is a plain boolean
(planReload false, planConfirmedReload true after the confirm).
The parameter stays optional rather than required: it trails other
optional parameters, which TypeScript forbids for a required one.
Gate C18 r1 2ab [Medium, toothless]: reverting the planConfirmedReload
wiring in index.ts / session-tile-actions.ts left every test green. The
existing deep-regenerate tests now assert the confirm is asked once, a
decline sends no prompt.submit (main surface), and an accept sends
confirm_deep_truncate. Both go red when the wiring reverts to planReload.
Gate C18 r1 2c [Medium]: planConfirmedReload used
visibleUserMessageIndices, which skips failed user turns, but the
gateway persists a failed turn's user row and counts it. Regenerating
before a failed later turn sent no confirm_deep_truncate and got an
unexplained 4033 (it worked before the gate). Any later visible user row
now asks for the confirm.
Code-health ratchet: index.test.tsx (6584 > 6577) and test_tui_gateway_server.py
(23504 > 23503) may only shrink vs main, and the deep-truncate stack grew both.
Pure moves, bodies unchanged:
- the uploadComposerAttachment Windows-image staging test -> upload-composer-attachment.test.ts
- test_prompt_submit_empty_truncation_allowed_with_confirm -> test_tui_gateway_server_deep_truncate.py
(the sibling now covers both history-cut opt-ins)
Every existing truncation guard validates that the anchor exists and matches.
In #133716 the anchor was valid but named the wrong row (a stale Desktop
binding), and the gateway archived 107 live messages (430 -> 323) with no
friction — nothing bounded how much history one submit may drop.
_truncate_history_for_submit now refuses (4033, data: archived_messages,
archived_user_turns) when the cut archives more than one user turn and the
submit lacks confirm_deep_truncate. One turn is the legit implicit-target
allowance: tail regenerate and edit-last-message drop exactly that turn.
History and DB stay untouched on refusal.
Desktop: restore and edit name their target explicitly, so runRewindSubmit
sends confirm_deep_truncate by default (via truncateSubmitParams); regenerate
sends it only after the Half-A confirm. The TUI sends no truncation params.
Contract field added to PromptSubmitParams and the generated TS/OpenRPC
regenerated with scripts/gen_gateway_contracts.py.
Trade-off: an older Desktop build against this backend gets 4033 on
restore/edit of an older turn until it updates.
Fixes#133716
Reported-by: @Enferlain
A regenerate's target comes from the bound message scope. In a long-lived
renderer that binding can go stale and name a turn hours back; planReload
trusted it, and the truncating prompt.submit silently archived every later
turn (430 -> 323, 107 live messages, in #133716).
planConfirmedReload (rewind.ts, shared by the primary chat and session tiles)
now routes a reload that would drop later user turns through the same
confirm restore-checkpoint uses (shared ConfirmDialog via store/confirm,
existing restore copy — no new strings). Declining submits nothing. The
normal tail regenerate has no later user turn and is unchanged.
Refs #133716
Reported-by: @Enferlain
Plugins that wanted a built-in app action (toggle the browser panel, open
Settings) faked its keyboard shortcut with a synthetic KeyboardEvent; that
silently breaks when the user rebinds or unbinds the shortcut
(browser-toggle catalog plugin).
- ctx.runAction(id) runs the same handler the keybinding dispatcher and
palette use: useKeybinds publishes its handler table through
registerBuiltinActionRunner, read at call time, so bindings never matter
- allowlist of view/navigation actions only (PLUGIN_APP_ACTIONS, typed
PluginAppActionId union); destructive / privileged ids are refused
- result-shaped, never throws: { ok: true } or { ok: false, reason:
'unknown' | 'denied' | 'unavailable', error } plus a console warning
- ctx.listActions() lists allowed ids with localized labels
- docs: API section, allowlist table, migration-table row; SDK exports
Review on #136313 (Greptile):
- The overlay offered "Change" to a custom-branch checkout and to runtimes
older than --set-channel, but Settings shows no selector for either, so
the link landed nowhere. The label stays; the action is passed only when
sourceUpdateChannel() allows a choice.
- An older checker omits aheadOfRelease yet may also pin an ahead checkout
to its own HEAD, so a missing field read as "on the release". source_check
now always sends the boolean, and the status line names the release only
on an explicit false.
The updates overlay (status-bar version click) showed no channel, so a
source install could not tell it was pinned to stable releases.
- VersionDetails (shared by About and the overlay) gains a Channel row for
source checkouts: "Stable releases", "Every commit (main)", or
"Branch: <name>". Packaged installs render no row; their channel is the
package's.
- The overlay passes a "Change" link that closes the dialog and deep-links
to Settings > About > Updates, where the selector and its confirm step
live. The channel is not switchable from the overlay itself.
- Current on stable, the status line names the release ("You're on the
latest stable release (v0.21.6)."). A forward-only checkout ahead of the
release keeps the generic line: source_check now reports
`aheadOfRelease`, since its pinned target is HEAD and would otherwise
look identical to being on the release.
baab4808d0 routed the stable lookup's GitHub reads through
source_check._request, so hermes_cli/source_check.py is now imported twice in
the probe (the CLI entry, then the module source_releases reads through). The
fixture's transport.py runs on every import of that file, so the second copy
wrapped the first and saw the already-rewritten 127.0.0.1 URL:
`assert parsed.hostname in ('hermes-assets.nousresearch.com', 'api.github.com')`
failed and the stable leg returned release-unavailable. main's JS & TS checks
have been red on this since 66605471e9.
transport.py now installs the redirect once (a marker on urllib.request). No
assertion changed. Locally: 1 failed on main, 1 passed with this.
configureProvider() rewrote config.yaml from scratch for every spec, after
the seed had pinned `update.installs.<id>: main`, so the three Desktop update
specs still saw the stable default (release run 38078988253: the update check
answered {"channel":"stable","error":"release-unavailable"}). It now carries
the seed's top-level `update:` block into the provider config it writes.
#135847 made an official-origin source checkout default to the stable channel,
which resolves only from GitHub's latest published release. The upgrade-network,
Windows install+update and Desktop update harnesses keep the official URL as
origin, publish commits on main and never a release, so every update in them
stopped at the stable default:
- e2e-upgrade (network): "Could not resolve the stable source channel: No
published stable release could be verified" (9 failed)
- Windows install + update E2E: "Release: stable v0.21.6; this checkout is
newer and waits for the next release", checkout never moved (12 failed)
- Desktop update E2E (app-update, update-concurrency, build-fail): the update
check answered {"channel":"stable","error":"release-unavailable"}
These suites test moving between commits, so each seeded install now records
`hermes update --set-channel main` right after its installer (new
_install_helpers.pin_main_channel; Windows Machine.install). Stable resolution
keeps its own coverage: test_release_channel_records sets stable explicitly on
the same seeded install, and tests/hermes_cli/test_source_release_channels.py.
Local: tests/e2e/core/upgrade/network on main plus this change, 21 passed
(4 failed in test_offline_and_flaky.py on main before it).
Review findings on #135949 (Greptile, @unsupportedpastels):
- An older runtime (v0.21.6) answers checks but rejects --set-channel, so the
selector failed on every click. source_check now marks its reply
`channelSelectable`; Desktop shows the row only when the install says so.
- The confirmed choice painted only after the forced check returned. The row
now shows it at once, and a failure re-reads the install instead of guessing.
- A checkout following a custom branch rendered as "Every commit" (main). The
row now appears only for stable, or for main itself.
Since #135847, stable is the latest published GitHub release
(`releases/latest`), not the R2 release-candidates pointer. The fixture still
served only the pointer, so the check reported "No published stable release"
and the test went red on main (the Python-only PR skipped the Desktop lane).
The local-setup card appeared above the input after the first finished turn
on any machine whose catalog recommends a local model, and stayed across
relaunches until dismissed. It read as a nag on every new session.
The offer keeps its quiet homes: the "Run locally" row at the top of the
model menu (until setup completes) and the handoff tour's model-pill step.
The card's state machine (armed/shown/dismissed, the message.complete hook,
the menu-open request) existed only for the card, so it goes with it; the
store keeps the eligibility read both remaining surfaces use.
The void-tag pass from the previous commit fixes `<br>`, but mermaid's
label HTML carries more than void tags: a non-breaking space (` `,
mermaid's `#nbsp;`, or a literal U+00A0 in the source) is serialised as
the ` ` entity, which XML does not define. Such a label still
decodes to a broken image.
mermaid.render() returns the HTML serialisation of the diagram, so parse
it with the HTML parser it was written for and re-serialise it with
XMLSerializer. That fixes the whole class at once. Against mermaid
11.16 in Chromium, across flowchart, class, state, ER, mindmap, block,
kanban, sequence, journey, gantt, pie, timeline, gitGraph, quadrant and
xychart output, the round-trip yields the same element tree and image
size as the regex wherever the regex output is valid XML, and also loads
the non-breaking-space cases the regex leaves broken.
Drop a label's plain `xmlns` attribute before serialising: the
serializer declares the namespace itself, and a spec-conformant one
(jsdom) writes both, a duplicate attribute.
The tests' fixtures put `<br>` directly in SVG <text>, which mermaid
never emits; replace them with the real 11.16 label shape (XHTML in a
<foreignObject>) and assert the label keeps its line break and its
non-breaking space.
Refs #133089, #134031
`mermaid.render()` returns SVG aimed at inline HTML, and its sanitizer
gives back a `<br/>` written in a label as an open `<br>`. That string is
not well-formed XML, so the strict `image/svg+xml` parse in
`normalizeSvgSize` yields the parsererror document instead of an `<svg>`,
takes the `el.tagName !== 'svg'` early return, and hands the *unnormalized*
string back. The diagram `<img>` then gets it as a `data:` URI, which Blink
decodes as strict XML and rejects for the same reason — so the message shows
a broken image whose alt text reads "Mermaid diagram", with nothing thrown
and nothing written to any log.
Close HTML void tags before the markup reaches either XML parse. The pass is
generic rather than a `<br>` special case, because the same class of input
reaches `<hr>`, `<img>`, `<input>`, …, and it is idempotent: already-closed
tags and non-void tags (including names that merely start with a void tag,
such as `<break>`) pass through untouched.
Fixes#133089
Verified by replaying 32 real ```mermaid fences from a session database
through the app's own renderer chunks in Electron 40.10.2 / Chromium 144:
24/32 fail before this change — every failure has `<br/>` in a label, with a
perfect 1:1 correlation — and 31/31 renderable diagrams succeed after it. The
32nd is an invalid Mermaid source that correctly keeps falling back to its
source block.
Plugins can now put a short plain-text line in the core pet's speech bubble
instead of locating the pet canvas in the app DOM and floating their own
overlay over it (pet-wallet, #135178).
- ctx.pet.say(text, { id?, tone?, ttlMs? }) -> disposer; ctx.pet.clear(id?);
ctx.pet.visible (ReadableAtom<boolean>)
- host-owned rendering in PetBubble, in-window (plugin lines only) and in the
pop-out overlay (lines ride the existing pet-state IPC push)
- plain text, 120-char cap, control/bidi chars stripped, plugin-name label,
TTL 6 s default (1-30 s), 3 live lines and 10 says/10 s per plugin
- core error / waiting states keep the bubble; nothing shows without a pet
- every line a plugin has up is cleared on unload/disable/reload
- docs: API section, migration-table row for pet-wallet, SDK exports
When no resident model cleared 20 tok/s, the resolver still recommended the
fastest one (fastest-resident). A "32 GB" machine without a discrete GPU reads
31.5 GB, where only the 27B fits, at a predicted 13 tok/s, so CPU-only and
integrated-GPU machines were steered to a slow dense model. Those machines now
get no recommendation, and every fitting model stays one click away in Browse.
The no-recommendation copy now says the model must run at full speed, the user
guide states the rule, and the unused fastest-resident tooltip string is gone
from every locale.
The composer's "This could run on your computer" card took the recommended
catalog row, else the first row marked `fits`. A row fits when any build runs,
including one spilled to system RAM, so a GTX 1080 with 16 GB of RAM was
offered the 27B, which the resolver never recommends there. The card now names
the recommended model, and a machine without one gets no card.
Enabling a plugin with Python dependencies, and `hermes pm install`, failed
in the packaged Windows app: uv lock/sync/pip check exit 101 with "Unable to
create process using ...\venv\Scripts\python.exe".
uv queries a venv by running its Scripts\python.exe. That redirector lives
outside the package and CreateProcess's the interpreter named by
pyvenv.cfg `home`. 6a031bf1b3 moved generations onto the packaged
interpreter, so `home` names a file under WindowsApps that a process without
package identity may not start (exit 101 is the redirector's
RC_CREATE_PROCESS). Restore the verified writable copy of the pinned Python
in the user store as the build interpreter for sealed Windows payloads.
venv_command (also from 6a031bf1b3) stays: Hermes still enters those venvs
on the packaged interpreter, so its own children keep package access to the
bundled git, rg and PM worker.
FadeScroll tracked clipped edges in React state, with a scroll handler and a
resize observer, then rebuilt an inline mask on every change. Move that to a
`scroll-fade-y` utility: the mask's stops are scaled by two registered
<number> properties that the scroller's own scroll position animates
(animation-timeline: scroll(self y)), so an edge only fades while content is
clipped behind it and a list that fits is never dimmed. No listener, no state;
a browser without scroll-driven animations shows no fade.
From each edge inward the mask is `pad` of fully hidden content, then `fade`
of gradient, both optional props. The onboarding apps list sets pad to 1.5rem
so the gradient starts at the chips instead of spending itself on the empty
padding at the box edge. edgeMask/scrollEdges stay for the profile switcher.
The connector/plugin chip picker is height-capped, so the last rows were
clipped with no hint there was more. Wrap the grid in the existing
FadeScroll, which already fades whichever edge has content clipped behind
it and leaves a list that fits untouched.
The main-model apply test raises a success toast, which arms a 5 s
auto-dismiss timer. Nothing cleared it, so when it fired after the file's
jsdom window was gone, vitest reported "window is not defined" from
dismissNotification as an unhandled error and failed JS & TS checks with
every test green. afterEach now calls clearNotifications().
The cache lived inline in vite.config.ts, and the renderer builder tests use a bare fixture
config, so nothing exercised it: a broken warm path would still pass every test.
Move it to scripts/compiler-cache.mjs (behaviour unchanged; toolchain files and cache root are
now parameters, and an existing closeBundle hook is chained instead of replaced) and test hit
vs fresh output, toolchain/NODE_ENV generations, corrupt entries, pruning, incremental (watch)
rebuilds and the dev-server bypass. A cold and warm production build of the real app stay
byte-identical. Reported by Greptile on #135092.
nativeTreeComplete decides whether a later build may skip restaging, but never looked at
node-pty. A partially extracted package with pty.node and no spawn-helper was recorded
complete; repairing the installed package without a lockfile change then left the staged
copy broken, and every later build shipped a terminal that fails on its first spawn.
Check the layout node-pty itself loads (build/Release, build/Debug, then prebuilds, as in
lib/utils.js) for a binding, and on macOS for the spawn-helper beside it (lib/unixTerminal.js).
Reported by Greptile on #135092.
The compiler emits HMR cache-reset code when NODE_ENV is development, and the cache key ignored it, so a dev-server session could seed entries a later production build reused. A dev server also wrote one entry per save with no build to prune them. The cache now wraps only 'vite build', NODE_ENV joins the toolchain key, and entries drop the map's sourcesContent (the module text the key already hashes; restored on a hit): 22 MB -> 15 MB. Verified: warm == cold byte-identical without sourcemaps; with --sourcemap two cold builds already differ in one map's __VITE_ASSET__ placeholder order, so that diff is vite's, not the cache's.
rendererCurrent and productCurrent share hostReceipt() for the schema/product/host checks. The stamped outputs come from bundleElectronMain's own electronOutputs list, so a renamed or added bundle can never be copied stale from the previous dist. The reuse copy skips node_modules/ and native/, which copyNativeTree recopies from nativeDeps right after, and a --typecheck build never reuses a renderer that skipped tsc.