On Windows with high-DPI displays (150%+ scaling), Chromium
re-evaluates zoom on focus change (alt-tab). The persisted zoom
level was only re-asserted on show/restore/resize/move events.
Add 'focus' to zoomReassertWindowEvents() so the zoom level is
restored when the window regains keyboard focus, covering both
main and secondary session windows through the existing
installZoomReassertOnWindowEvents() abstraction.
Extend zoom.test.ts to exercise the registered focus handler.
Fixes#50837
renderer-bundle.ts shipped in #85887 as pure/injectable but had no unit
coverage. Lock the contract that heals a torn self-update: the boot loader
must prefer a complete generation and only report a repair when every copy
is torn.
parseModuleAssetRefs: module scripts + modulepreload only (not stylesheets
or classic scripts), CDN/absolute refs dropped, ./ and query/hash stripped.
missingRendererAssets: intact -> [], torn -> the dangling chunk(s),
per-copy dir-relative existence (the split app.asar vs app.asar.unpacked
case), and an unreadable/module-free index is not treated as torn.
Sits under 'New window' in the Open group and resumes the chat in the user's
own terminal, starting in the session's workspace and pinned to its profile.
Hidden on a remote connection, where the emulator is local but the session
isn't.
Resolves the same backend the app launches (usually a venv python running
-m hermes_cli.main), writes the launcher script, and spawns the terminal
detached so it outlives the app. Resolution only — never ensureRuntime, which
would start a first-run install from a menu click.
Pure helpers for handing a session to an external terminal: the
`--tui --resume <id>` argv, a launcher script that carries the resolved
runtime's command and PYTHONPATH, and per-platform emulator resolution.
macOS opens the .command with no -a so LaunchServices routes it to whichever
app the user bound to shell scripts; Linux leads with Debian's
x-terminal-emulator alternative before the concrete emulators; Windows prefers
Windows Terminal over a cmd console.
sharedPrimaryRoute() inferred "served by the shared primary backend" from
the mere presence of connection.profile. But pooled backends (a local named
profile, or a per-profile remote override) also carry `profile` so their
WebSocket URL mints against the right backend. Both descriptors looked the
same, so ensureGatewayForProfile() took the shared-primary branch for a
pooled profile and never dialed its socket — Desktop stayed on the default
profile's socket even though the sidebar (REST) listed the right sessions.
Regression from #85665 (d16e236). Tag only the true shared-primary
descriptor with an explicit `sharedPrimary: true` marker in ensureBackend()
and check that marker instead of `profile`. Covers both the local-pool and
remote-override routes — the whole bug class, not one path.
Test asserts both sides of the invariant: a { profile, sharedPrimary: true }
descriptor activates the primary socket without dialing, and a pooled
descriptor carrying { profile } dials its own exact WebSocket URL.
Supersedes #85750, #85778, #85932Fixes#85777
Co-authored-by: Tigrannnnnnn <122704900+Tigrannnnnnn@users.noreply.github.com>
Co-authored-by: Don Tuttle <11698271+wdon@users.noreply.github.com>
Co-authored-by: plcunha <145560011+plcunha@users.noreply.github.com>
Same local-ahead blind spot as the CLI SSH fast path, on the desktop's
passive SSH-official check: tips differ but ahead_by == 0 means the remote
tip is reachable from HEAD (carried local commit). Treat that as up to date
instead of 'update available' — the nudge toward hermes update is exactly
what wipes carried work.
Widens andyst-dev's #84860 to the desktop sibling site.
The composer read the same image off disk twice: once in attachImagePath
for the chip thumbnail (previewUrl — the FULL file as a base64 data URL),
and again inside uploadComposerAttachment at submit for the upload bytes.
readImageForRemoteAttach now accepts the attachment's previewUrl and
reuses its bytes when it is a base64 data URL, skipping the second disk
read + IPC round-trip. Anything else (e.g. a gateway media URL) falls
through to the disk read unchanged.
Flagged in #86302's renderer bench as the one real renderer-side
inefficiency on the attach path.
Renaming a session from its row menu opened the rename dialog, but the
menu's close restored focus to its trigger — the session row's own
<button> — instead of leaving it in the dialog input. Focus sat on the
row, so Space toggled the row (selecting/deselecting the session) and the
arrow keys moved the list rather than the text caret; you couldn't type a
space or move within the name.
Thread onCloseAutoFocus through the shared ActionsMenu / ActionsContextMenu
primitives and suppress that one focus-restore when the rename item is the
action that closed the menu, so the dialog input keeps focus. Every other
action leaves the restore untouched. Mirrors the project menu's existing
appearance-popover guard.
Ports Claude Code's /loop (and its /proactive alias) across every Hermes
surface. /loop [interval] <prompt> re-runs a prompt or slash command on a
recurring cadence inside the live session; omitting the interval enables
self-paced mode (starts at the floor, backs off exponentially while the
agent's replies stop changing, snaps back on change — local digest
comparison, zero extra LLM cost).
Stop conditions: agent-emitted LOOP_COMPLETE marker, --times N,
--until <condition> (judged by the existing goal_judge aux task,
fail-open), /loop stop, and a loops.max_ticks backstop budget.
Core: hermes_cli/loops.py (LoopState + LoopManager + shared
dispatch_loop_command), persisted per session in SessionDB state_meta
(loop:<sid>) so /resume picks it up; migrates across compression
boundaries like /goal. New SessionDB.list_meta_prefix() powers the
gateway's cross-session scan.
Surfaces:
- CLI: /loop handler + idle-fire and post-turn-complete hooks in
process_loop (mirrors the /goal hook shape; Ctrl+C pauses the loop)
- Gateway: /loop handler with route capture, mid-run control-verb guard,
post-turn tick completion, and a supervised loop_wakeup_watcher that
injects due wakeups into idle chats via the synthetic-message path
- TUI/dashboard/desktop: command.dispatch handler + per-session
notification-poller wakeup driver + post-turn completion in the turn
dispatcher; /loop added to the desktop slash palette
- /goal mixing: an active non-parked goal owns the idle boundary — loop
ticks defer until it finishes, pauses, or parks; real user input always
wins over both
Config: loops.{min_interval_seconds,max_ticks,self_paced_floor_seconds,
self_paced_ceiling_seconds}. Docs page + sidebar entry. 77 new tests.
Slack's 50-slash cap: /version moves to /hermes version to free the
native slot for /loop.
The merged data-attributes only exposed data-sessions-project on the
sessions wrapper once a project was entered, so in the project overview
(and every other mode) the attribute was absent. Put it on each project
overview row too, carrying that row's project id, so a custom skin can
target an individual project from the list — the parallel to the entered
wrapper's attribute.
The bench timed two of the six RPCs the fix touches. Extend it to
image.attach, pdf.attach, clipboard.paste and image.detach so every changed
handler carries a number rather than an inference.
Also report which surfaces reach these RPCs at all, since "why was the GUI
special" is the first question the fix invites. CLI attaches inline in its
own turn path with the agent already built, so it cannot reach the stall;
the TUI calls the same RPCs and was equally exposed. The difference was hit
rate, not code path.
gateway_attach_bench.py drives the real dispatcher with a session whose agent
build is still running and times each attach RPC against prompt.submit as the
control — the harness that located the stall and measures it.
image-attach-bench.mjs times the renderer-side transforms (file read, base64,
RPC frame, embedded-image extraction, render-weight walk) across image sizes.
It is what ruled the renderer out: ~26ms total at 3MB.
The honesty half (no fabricated counts) leaves shallow installs permanently
count-less. The compare API knows the full graph regardless of local clone
depth: GET /repos/<o>/<r>/compare/<current>...<target> returns ahead_by —
exactly the behind count the shallow boundary lost.
- hermes_cli/banner.py: _github_compare_behind() (bounded, unauthenticated,
best-effort); wired into _check_via_rev and the shallow branch of
_check_via_local_git. ahead_by==0 with differing tips = local-ahead => 0.
- hermes_cli/update_cmd.py: hermes update --check shallow path prints the
exact count when recoverable, presence-only wording otherwise.
- apps/desktop/electron/update-count.ts: compareApiUrl() +
parseCompareBehindCount() pure helpers; main.ts fetches the count when
resolveBehindCount() returns null, and the SSH-official passive path stops
fabricating behind:1 (uses compare API + updateAvailable flag).
- apps/desktop/src/lib/version-status.ts: updateAvailable now applies to the
client target too, so a shallow desktop install shows '(update)' instead of
nothing (or the old frozen '(+1)').
Fixes#84591; CLI siblings of #78253 / #53479 behavior.
E2E: live compare API returned 61/62 for real 61/62-commit gaps and 0 for the
reversed (local-ahead) pair; real shallow-clone fixture (depth-1 clone +
depth-1 fetch, merge-base broken) recovers the exact count with the API and
falls back to the honest sentinel offline.
On an installer checkout (clone --depth 1) with no merge-base against the
freshly fetched origin tip, resolveBehindCount returned the sentinel 1 and
every surface rendered it as a literal count: 'A new update is ready (1
change included).' — even when the true distance was far larger (observed:
90 commits). The sentinel was meant to mean 'update available, exact count
unknown', but nothing downstream distinguished it from a real one.
- update-count.ts: return null (unknown) instead of the numeric sentinel
- main.ts: flag updateAvailable explicitly and still serve the (capped)
commit log so 'See what's new' stays useful in the unknown case
- updates.ts: toast fires for behind:null + updateAvailable, with
count-free copy instead of being swallowed by the <= 0 guard
- about-settings.tsx: status line and action buttons key off
updateAvailable; unknown size renders the new count-free string
- i18n: updateReadyUnknown / updateReadyMessageUnknown in all 5 locales
Refs #51922 (the shallow-clone special case this UI now renders honestly).
Tests: vitest electron 10/10, ui 44/44 (3 FAIL-BEFORE reds turned green),
tsc typecheck clean, eslint clean on all touched files.
Review findings: spawnSync('npm', ...) without shell fails on Windows
(npm is npm.cmd; Node >=18.20 throws EINVAL — same handling as
test-desktop.mjs and stage-native-deps.mjs), and a spawn-level failure
exited 1 with no diagnostic. CI is ubuntu-only but the desktop workspace
supports local Windows dev.
Closes the silent-skip hole reviewers flagged: with the index/count
hardcoded in three sibling strings, a copy-paste slip (shard-2of3 running
--shard=1/3) or a partial 3->4 migration would silently skip a third of
the 428-file suite while CI stays green.
scripts/run-ui-shard.mjs parses N/M from npm_lifecycle_event (the script
NAME is the single source of truth), validates the package's shard family
is exactly 1..M for one M, and delegates through 'npm run test:ui' so the
vitest command stays single-sourced.
Mutation-verified: shard-9of3 name -> exit 1 'index out of range';
adding shard-4of4 beside the 3-family -> exit 1 'must form exactly 1..M';
correct invocation runs shard 2/3 (142 files) identically to before.
Review finding (reuse): every other check:* script in this file delegates
to its base script, keeping the runner command defined once. The shard
scripts inlined 'vitest run --project ui' three times, so a later change
to test:ui (flags, project rename) would silently drift from what CI runs.
Route them through 'npm run test:ui -- --shard=N/3' instead (npm forwards
post---- args).
Verified: npm run check:test:ui:shard-1of3 passes (142 files) with
identical file distribution.
The apps/desktop check:test:ui job is the slowest required check
(~5m40s wall; vitest self-report: tests 101s, environment 345s,
import 302s). With 425 isolated jsdom test files, per-file env boot
and module-graph re-import dominate — the tests themselves are ~100s.
Replace the single check:test:ui script with three check:test:ui:shard-NofM
scripts using vitest's built-in --shard. The workspace-discovery matrix in
js-tests.yml already fans out every check:* script as its own job, so no
workflow changes are needed.
Measured locally (8-core, same suite):
unsharded 234s
shard 1/3 + 2/3 + 3/3 70s / 80s / 73s (141+141+141 files, all pass)
Projected CI gate path: ~5m42s -> ~2m20s per shard in parallel.
--no-isolate was evaluated and rejected: 3.5x faster but 103 test files
(533 tests) fail without isolation. pool=threads was a wash; vmThreads
was slower. The local 'npm run check' aggregate now calls test:ui
directly (identical unsharded behavior as before).
Clicking an agent in a multi-profile roster pays the entire backend
spawn + WebSocket dial cost on first open — several seconds of
'loading' (Bot Mode report). Expose the existing pool-only primitive
(openGatewayForProfile: opens/pools the socket WITHOUT activating it,
already no-ops for the primary and shared-remote routes) as
host.warmProfile(name) so rosters can pre-dial after mount and the
first click lands on a live socket. Fire-and-forget by design;
failures stay silent — the real open path re-runs its own ensure.
The bare dimmed glyph had nothing to read against. Over a light document in
a light theme it is a pale mark on white, and every rest opacity tried
(0.35, then 0.45, then 0.75 behind a text halo) came back reported as the
button being gone.
Give the control its own substrate, which is what every shipped overlay
does: Apple's HIG puts controls on a material rather than directly on
content, Firefox picture-in-picture draws close/unpip as opaque chips, and
Discord's overlay adds a contrast layer over the game. Deriving contrast
from the backdrop is not available to us either way -- mix-blend-difference
composites against the page, and behind a transparent Electron window that
is nothing.
The chip now wears the composer bar's own tokens (fill, hairline, radius,
bottom shadow), so it inverts with the theme and with the OS appearance
under mode 'system'. It rests hidden and fades in while the bar, the band,
or the chip itself is hovered, with a hold on the way out so it survives the
reach across the gap -- reaching for the HUD is the motion that means "I
want the app". Hover rather than focus: the caret gate behind #81893 broke
the escape hatch exactly when it was needed.
resolveAgentAvatar cached null permanently (window lifetime), so a bot
whose avatar reached the asset store moments after its first notice
rendered — freshly created bots, art backfills in flight — kept the 🤖
glyph until an app restart even though profiles.get_asset had the pfp
(user report: brand-new bots' notices never picked up their faces).
Hits stay cached for the window; misses re-probe after 30s. Same
dedupe/inflight behavior otherwise.
* fix(desktop): load the intact renderer bundle when an update tears one copy
index.html and the hashed chunks it names are one generation. A packaged app
ships that bundle twice (inside app.asar and, via asarUnpack, beside it in
app.asar.unpacked), so an update that replaces the app while its files are
locked can leave the two copies from different generations. resolveRendererIndex
took the first index.html that existed, so it could pick the torn one and the
window died on its first lazy import with "Failed to fetch dynamically imported
module" -- with no way out, because every relaunch reloaded the same copy.
Check each candidate's declared modules and prefer a complete generation; when
both are torn, log which files are missing and how to repair instead of leaving
the crash unexplained.
* fix(cli): rebuild the desktop app when its renderer bundle is half-replaced
The content stamp hashes the SOURCE tree, which an interrupted update leaves
intact, so `hermes desktop` reported "up to date" and skipped the rebuild that
would repair a torn bundle -- the app relaunched into the same crash and
reinstalling looked like the only option.
Treat a bundle whose index.html names missing chunks as stale regardless of the
stamp, and say so on the way into the rebuild.
The sending bot's chat showed inter-agent deliveries as raw terminal
tool rows (the hermes -p … chat … command + output transcript) —
plumbing, not conversation. When a terminal call matches the delivery
convention (-p <agent> chat … -q "Message from …", the shape from
#85855), it now renders as compact centered notices instead:
'Messaging <agent>…' while running, 'Messaged <agent>' on completion,
and — when the quiet run returns the recipient's reply — a 'Message
from <agent>' notice with the text behind a 'show message' expander.
Avatar resolution reuses the #85855 helper (now exported); glyph
fallback everywhere it can't resolve. Failed commands keep the real
terminal row (debuggable). Ordinary terminal calls untouched.
Sender + receiver now speak one visual language: the exchange is a
pair of timeline events on both ends (Grok-bots parity), with #85884
collapsing the receiving side's reply.
agent-delivery tests 6/6; thread suite 20 files / 117 tests green.
The recipient's reply to an inter-agent delivery rendered as a full
assistant message, so the receiving bot's chat read like a normal
human conversation. The exchange is an EVENT in that bot's timeline,
not conversation content: when the immediately preceding user message
is an inter-agent delivery (AGENT_MESSAGE_RE, shipped in #85855), the
reply now renders as a compact centered 'Replied to <sender>' notice
with the full text behind a 'show reply' expander — mirroring the
delivery notice above it. Never collapses while streaming (progress
stays visible); ordinary assistant messages untouched.
Thread suite 19 files / 111 tests green.
Add data-sessions-mode ('flat' | 'projects' | 'project' | 'archived' |
'search') and data-sessions-project (entered project id) to the sidebar
sessions wrapper so custom UIs can target project mode without relying
on internal class names.
* feat(desktop): render agent-to-agent messages as attributed cards, not user bubbles
Bot-to-bot deliveries arrive on the user role (alternation requires
it) but are not the human speaking — they rendered as if the user
typed them. Detect the delivery prefix ('Message from 🤖 <sender>: …',
emoji-less, and the legacy bracket form) and render an attributed
inter-agent card: left-aligned, robot + sender header, 'agent message'
label, body through the same minimal markdown pipeline. Anchored regex
cannot fire mid-prose. Same pattern as ProcessNotificationNote.
Presentational only; content/roles/caching untouched.
* reshape: inter-agent card -> Grok-style compact timeline notice
Per maintainer screenshot: the delivery renders as a subtle centered
'🤖 Message from <sender>' notice (ProcessNotificationNote's shape),
with the delivered text behind a 'show message' expander instead of a
full-width card. The recipient's reply remains a normal assistant
message below it.
* feat: sender avatar on the inter-agent notice
The delivery prefix may carry the sender's profile handle —
'Message from 🤖 <Display Name> (@<handle>): …'. The notice resolves
it through profiles.list (has_avatar) + profiles.get_asset and renders
the sender's actual avatar in place of the 🤖 glyph, with module-level
memoization (one resolution per sender per window) and inflight
de-dup. Glyph fallback covers handle-less prefixes, older gateways
without profiles.*, avatarless profiles, and failures. 'hermes'
resolves to the primary profile by convention. Tests 6/6.
* lint: sort the $gateway import (perfectionist/sort-imports)
Picking a colon-less completion row (agent profile mentions from
complete.path, e.g. '@mr-tester') fell through serialize()'s typed-
reference branch: classify() marks colon-less entries type 'simple'
with insertId = text, so the serializer minted '@simple:`@mr-tester`'
— which rendered as a weird chip in the composer AND broke downstream
@mention routing (the backtick-quoted form no longer parses as a bare
mention). Colon-less rawText now inserts verbatim, matching what @diff
and @staged already did via the empty-insertId path.
Test: mention rows serialize to plain @name and commit to the editor
without an @simple: wrapper (directive-label 5/5).
Closing a pane contributed by a plugin used to disable the entire plugin,
unloading every one of its contributions. For a plugin that owns several
independent panes (e.g. Bot Mode's Cronjobs pane alongside its Bots roster
and composer middleware), closing one pane silently killed the rest.
Now: closing one pane of a multi-pane plugin dismisses only that pane; the
plugin stays enabled and its other panes/commands/middleware keep working.
Reset layout restores dismissed contributed panes. A single-pane plugin
keeps the existing symmetric behavior (Close disables the plugin, with
Settings -> Plugins as the recovery path).
Adds regression coverage for both cases.
main's check:lint (tsc) is red: 071d27d1c3 added a required update()
member to ComposerActionsScope, but the use-composer-actions.test.ts
scope double was never extended. TS2741 at line 294.
Both connect providers captured `sessionId` when the suggestion was built
and ignored the one the pill hands `invoke`. An offer that outlived a
session switch therefore aimed its `reload.mcp` at the session the draft was
sampled in, so the chat the user actually clicked from resumed without the
tools the pill just said were ready.
Prefer the invoking pill's session; the captured one stays as the fallback.
Phase lived in a `Record<key, phase>` that only ever grew, keyed by
`provider:id` — keys that repeat constantly, since a provider withdraws and
re-offers the same suggestion whenever the draft loses and regains its
trigger. A leftover `done` then painted a genuine new offer as "Added
GitHub" and swallowed clicks, because only `idle` invokes.
The same map outlived a session switch. One composer stays mounted across
it, so connecting GitHub in one chat left the next chat's real offer inert.
Withdrawal also stranded in-flight work. The pill is the only cancel
affordance — clicking a working pill sets the flag the provider polls — so
once it left the strip an OAuth flow could poll forever, hold the server's
in-progress slot against a retry, and resolve into a config write with no UI
left to narrate or roll it back.
Phase now lives and dies with the pill: withdrawn keys drop their phase and
flip their cancel flag, unmount cancels everything in flight, and the strip
remounts per session.
The bus's change gate compared offers by `provider:id` alone. Providers
rebuild their suggestion objects on every draft sample, so that key is equal
constantly and the write bailed out — pinning the FIRST object for the life
of the offer.
Two consequences, both user-visible. The pill keeps painting a stale reason
("you mentioned linear" after the user pasted a linear.app link), and it
keeps calling a stale `invoke` closure — work built for a draft that no
longer exists.
Compare the fields the pill actually renders instead. The reference-identity
bail-out survives for the common case (same draft, same match, no re-render),
which is what the gate was there for.
Vercel, Supabase, Netlify, Hugging Face, Asana, Intercom, Airtable,
Webflow, PayPal, and Square join the directory. Every entry is a
vendor-operated remote with its docs page linked, same URL-only rule
as the founding eight. Trigger notes where words are ambiguous:
'square' the English word never fires (squareup only), and
vercel.app/netlify.app deploy-preview hosts are deliberately absent
(a pasted preview link is about the site, not the platform). Brand
glyphs wired for all newcomers.
Two precision guards on the draft-keyword providers, both aimed at the
same annoyance: a pill firing while the trigger is still under the caret.
- Completed-word guard (mcp + skill): a whole-word keyword hit only
counts once at least one character follows it, so the debounce
elapsing mid-thought no longer pops a pill for the word being typed.
Pasted-URL host hits are exempt: pasting is deliberate and the URL
routinely ends the draft.
- Workspace homonym guard (skill): a skill named like the session's
working directory is the project's name, not a request. Working in
~/www/hermes-agent no longer floats 'Use skill: hermes-agent' on
every mention of the repo.
Under a global SSH/remote gateway, resolveProfileBackendRoute routes every
profile to the shared primary backend (case 3) and getConnection() returns
the primary descriptor tagged with the profile. ensureGatewayForProfile
still dialed a per-profile secondary socket at that descriptor; over SSH
the duplicate dial fails (per-backend tunnel/ticket) and the closed socket
became the ACTIVE gateway — every profile except the primary showed
'Hermes gateway is not connected' even though the primary socket was open.
Detect the shared-primary route and activate the primary socket instead;
$activeGatewayProfile still tracks the selected profile so per-request
?profile= scoping is unchanged. Hover pre-warm no-ops on this route.
Local pooled profiles and per-profile remote overrides are untouched
(pinned by test).
Once you have toggled a few items on and off there is no way to get
back to the shipped layout short of remembering which ids are in
STATUSBAR_HIDDEN_BY_DEFAULT. Add a row to the bar's right-click menu
that restores that set.
The row is disabled rather than hidden when nothing is customized, so
it also advertises that a shipped layout exists. Reset touches item
layout only — whole-bar visibility is a separate preference, and
resetting from the bar's own menu should not make the bar you are
right-clicking disappear.
The whole-bar visibility atom defaulted to false (opt-in). Flip it to
true so the bar shows on first launch. The context-usage meter and
other diagnostic items remain hidden via STATUSBAR_HIDDEN_BY_DEFAULT,
so only the core status items (gateway health, model pill, command
center) appear out of the box. The toggle keybind and ⌘K row still
let users hide it.
* fix(install): time-box the Windows node-deps stage so a stalled npm or Playwright install can't hang setup forever
scripts/install.sh has bounded this same work with run_with_timeout
"$NODE_DEPS_TIMEOUT" (600s default) since #39219, but install.ps1 never got
the guard: Install-NodeDeps ran both `npm install` and `npx playwright
install chromium` unbounded. A stalled registry fetch or a wedged Chromium
archive extraction (#76222, #84614) froze the installer indefinitely -- one
user left it running 12+ hours overnight before asking for help.
Route both invocations through _Invoke-NativeWithTimeout: cmd.exe launches
the native command with its output merged to a log, the parent polls with a
wall-clock deadline and tails new log lines to the console each tick (the
live progress that makes a 3-minute download distinguishable from a hang),
and on timeout taskkill /T /F kills the real process tree and returns 124 --
the same convention as coreutils timeout and bash's run_with_timeout.
Wait-Job was rejected for this: jobs swallow live output and Stop-Job leaves
the npm child running. Windows PowerShell 5.1-safe throughout.
Timeouts surface as a warning with the log path, a note that re-running the
installer resumes (stages are idempotent), and the NODE_DEPS_TIMEOUT env
override for slow links -- mirroring bash.
Fixes#76222.
Closes#84614.
Supersedes #76303.
Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
* fix(installer): roll stage timers over to hours so an overnight stall doesn't read as "744 hours"
formatElapsed rendered a running stage as m:ss with unbounded minutes: a
node-deps stage left hanging overnight showed "744:38", which the user who
reported the hang understandably read as 744 hours. formatDuration
(completed stages) had the same unbounded-minutes shape.
Move both formatters into src/lib/format.ts (pure, no React) and add the
hour rollover: h:mm:ss live, "Xh Ym" completed. tests-js pins the shapes,
including 744m38s -> 12:24:38.
---------
Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>