Commit Graph

1256 Commits

Author SHA1 Message Date
Hermes Agent 87f7b76984 docs(pr-media): add before/after PNGs for #1618 fix (from @Michaelyklam #1641)
Adopt the UI media from @Michaelyklam's parallel-discovery PR #1641 which
shipped the same one-character regex relax fix for #1618. PR #1641 is
being closed as superseded by #1642 (which carries nesquena APPROVED +
322 LOC test suite); preserving Michael's UI evidence here so the visual
proof of the fix lives in-tree alongside the canonical PR.

Co-authored-by: Michael Lam <Michaelyklam1@gmail.com>
2026-05-04 18:25:46 +00:00
nesquena-hermes cbfc544f50 fix(renderer): YAML/JSON/diff code blocks lose newlines (#1618 / #1463)
Closes #1618 (reported by @Zixim) and corrects #1463's previous fix.

Bug: YAML, JSON, and diff/patch fenced code blocks render flattened to a
single line. Reporter noted the bug persisted v0.50.279 -> v0.50.291 ->
v0.50.292 despite PR #1516's CSS-only "fix".

Root cause: PR #484 (v0.50.237) added a JSON/YAML tree-viewer that routes
those languages through <div class="code-tree-wrap">...<pre class="tree-raw-view">
instead of bare <pre>. Same release added the diff/patch coloring path
that emits <pre class="diff-block">. The _pre_stash regex at
static/ui.js:1914 matched only literal <pre> with no attributes:

    <pre>[\s\S]*?<\/pre>

Both new shapes failed to match, fell through to the paragraph-wrap pass,
and \n characters inside the code blocks got replaced with <br> tags
inside <code>. By the time Prism ran, no newlines remained for the CSS
rule (PR #1516, language-yaml .token { white-space: pre !important }) to
preserve.

Fix: relax the regex to accept any attribute on <pre>:

    <pre>[\s\S]*?<\/pre>  ->  <pre[^>]*>[\s\S]*?<\/pre>

One regex character. Pulls JSON, YAML, and diff/patch blocks into the
stash so paragraph-wrap can't mangle them. Bash, Python, Go, etc. were
never affected because they emit bare <pre>.

Tests: 9 new (2 source-string invariants + 7 behavioural via node-driver
against the actual static/ui.js renderMd()). 6 of the 7 behavioural tests
fail on master and pass with the fix; the 3 sanity checks (yml-alias,
bash, mermaid) pass on both.

Plus widened source-scan window in 3 pre-existing test_745 assertions
from 400 to 1500 chars. The new comment block above the fixed regex
pushed it past the previous scan window. Pure window-narrowness bug,
not a behavior regression.

4245 -> 4254 passing.
2026-05-04 18:11:58 +00:00
nesquena-hermes 304a422814 Merge pull request #1638 from nesquena/stage-294
Release v0.50.294 — 3-PR batch (streaming stability trio + cache version stamp + race fix + readonly fs guard)
v0.50.294
2026-05-04 10:27:00 -07:00
Hermes Agent 326c7d0daf chore(release): stamp v0.50.294 — 3-PR batch + Opus pass
Constituent PRs:
  #1631 by @nesquena-hermes — streaming stability trio (closes #1623, #1624, #1625)
  #1635 by @bergeouss — session list race + readonly fs guard (closes #1430, #1470)
  #1636 by @nesquena-hermes — models cache version stamp (closes #1633)

Opus advisor SHIP verdict on stage-294 (combined diff). All 9 verification
questions cleared. Two #1636 minor observations absorbed in-release:
- DEBUG logger calls in _is_loadable_disk_cache when rejecting
- Docstring clarification on string-vs-semver and schema-version axis

#1631 in-PR Opus pass already absorbed: rate-limited telemetry,
expanded _LOCAL_SERVER_PROVIDERS, RFC1918 CHANGELOG callout.

4180 → 4245 tests passing (+65).
2026-05-04 17:23:32 +00:00
test 6bbf913e22 Stage 294: PR #1631 — streaming stability trio (closes #1623, #1624, #1625) by @nesquena-hermes — APPROVED 2026-05-04 17:13:08 +00:00
test c256501788 Stage 294: PR #1636 — models cache version stamp (closes #1633) by @nesquena-hermes — APPROVED 2026-05-04 17:10:34 +00:00
test c1b20bc602 Stage 294: PR #1635 — session list race + read-only fs guard (closes #1430, #1470) by @bergeouss 2026-05-04 17:10:34 +00:00
nesquena-hermes 66b925f59d fix(cache): stamp /api/models disk cache with WebUI version + schema version (#1633)
Closes #1633. STATE_DIR/models_cache.json was persisted across server
restarts without any version stamp, so a Docker container update from
version A to B read the cache file written by version A — users saw
stale picker contents (missing models, phantom provider groups) for
up to 24 hours until either the TTL expired, an unrelated provider
edit triggered invalidate_models_cache(), or they manually deleted
the file.

Reporter Deor (Discord) updated to v0.50.292 — which contained fixes
for #1538, #1539, and #1568 — did a hard refresh and cleared site
data, and still saw byte-for-byte identical picker contents because
the server kept reading the v0.50.281 cache file off the host-mounted
state volume.

Fix:
  * _save_models_cache_to_disk() stamps payloads with _webui_version
    (resolved lazily from api.updates.WEBUI_VERSION via sys.modules
    lookup to avoid the api.config <-> api.updates circular import)
    and _schema_version = 2.
  * New _is_loadable_disk_cache() validator checks both stamps in
    addition to shape. Mismatch on either field rejects the load.
  * _load_models_cache_from_disk() calls the new validator and
    strips the disk-only metadata before returning, so the rest of
    the code sees the same shape it always did.
  * _is_valid_models_cache() kept loose (shape-only) so in-memory
    cache writes that never touch disk don't fail validation.

Schema version is independent of the WebUI version stamp so future
cache-shape changes can invalidate older releases without relying
on a tag bump alone.

Early-init edge case (api.updates not yet loaded) skips the version
check rather than wedging the boot — at worst an unstamped file is
written once and rejected on the next call.

Updated existing tests/test_model_cache_metadata.py to use subset/
round-trip semantics rather than byte-for-byte equality, since the
disk payload now has additional stamps. The four response-shape
fields still round-trip verbatim; the load result is unchanged
(stamps stripped). 19 new regression tests.

4180 -> 4199 tests pass.
2026-05-04 17:03:02 +00:00
bergeouss 21ba37c486 fix: session list race condition (#1430) + read-only fs guard (#1470)
#1430 — renderSessionList() had no staleness guard. Multiple concurrent
callers (message send, rename, session switch) could race, allowing a
slower older API response to overwrite _allSessions with stale data.
Added a generation counter that increments on each call and discards
responses from superseded generations.

#1470 — docker_init.bash unconditionally called groupmod/usermod even
on read-only root filesystems (podman with read_only=true). Added a
writability check for /etc/group and /etc/passwd. If read-only and
UID/GID already match, the mod is skipped gracefully. If they don't
match, a clear error message suggests setting matching IDs or disabling
read_only mode.
2026-05-04 16:51:53 +00:00
nesquena-hermes 040cb8af70 Apply Opus pre-release SHOULD-FIX + NITs (in-PR per release policy)
SHOULD-FIX: rate-limit _repair_stale_pending repair-firing telemetry. Switch
from unconditional logger.warning to age-keyed: WARNING when pending_age <
5min (the diagnostically valuable race window — actual leak-path candidates
that slipped past the grace guard) and DEBUG for the long-tail (orphaned
sidecars from prior process lifetimes). Prevents reconnect loops on stuck
sessions from flooding the log while preserving the diagnostic signal we
want for tuning _REPAIR_STALE_PENDING_GRACE_SECONDS empirically.

NIT: _LOCAL_SERVER_PROVIDERS expanded with lm-studio (hyphenated alias used
in some custom_providers configs and already recognized at api/config.py:2189
for SSRF host trust) and localai (LocalAI project). Test parametrize expanded
from 7 to 11 names, also covering pre-existing koboldcpp and textgen for
symmetry. +4 regression tests.

NIT (docs): CHANGELOG callout for the RFC1918 behavior change. Internal-
network OpenAI-compatible proxies now preserve the model prefix on private-IP
base_urls. Documented the migration path: configure as a custom_providers
entry to bypass the local-server detection.

NIT (deferred, optional): narrowing the heuristic to is_loopback only is
left as future work; the broader scope was an explicit goal in the bug
body and Opus flagged it as SHOULD-DISCUSS-but-not-block.

4184 -> 4188 passing. 0 regressions. ~10 LOC absorbed total.
2026-05-04 16:50:22 +00:00
nesquena-hermes bea57beba9 fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.

Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.

Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.

42 regression tests across:
  tests/test_issue1623_sse_heartbeat_alignment.py (3)
  tests/test_issue1624_repair_stale_pending_grace.py (9)
  tests/test_issue1625_local_server_model_id_preservation.py (30)

4142 -> 4184 passing. 0 regressions.
2026-05-04 16:49:43 +00:00
nesquena-hermes 25cb35ee1a Merge pull request #1632 from nesquena/stage-293
Release v0.50.293 — 3-PR batch (profile isolation trio + agent version + #1597 follow-up)
v0.50.293
2026-05-04 09:36:37 -07:00
Hermes Agent f3e066b53c chore(release): stamp v0.50.293 — 3-PR batch + 2 Opus follow-ups absorbed
Constituent PRs:
  #1627 by @franksong2702 — show Hermes Agent version (closes #1606)
  #1629 by @nesquena-hermes — profile isolation trio (closes #1611, #1612, #1614)
  #1630 by @Michaelyklam — provider config cleanup regression test (#1597 follow-up)

Opus advisor SHIP verdict + 2 SHOULD-FIX absorbed in-release:
- load_projects() re-reads from disk inside lock to close migration startup race
- _detect_agent_version() uses --dirty for symmetry with _detect_webui_version()

4142 → 4180 tests passing.
2026-05-04 16:33:57 +00:00
test 838645fd50 Stage 293: PR #1629 — profile isolation trio (closes #1611, #1612, #1614) by @nesquena-hermes — APPROVED 2026-05-04 16:21:29 +00:00
test 341b4c7abd Stage 293: PR #1627 — show Hermes Agent version in Settings (closes #1606) by @franksong2702 2026-05-04 16:20:39 +00:00
test 7680b1de45 Stage 293: PR #1630 — provider config cleanup regression test (#1597 follow-up) by @Michaelyklam 2026-05-04 16:20:39 +00:00
nesquena-hermes 6bc0f9c4d5 Apply Opus pre-release SHOULD-FIX + NITs (in-PR per release policy)
SHOULD-FIX #1 (renamed-root client cross-alias): drop strict-equality client
filter at static/sessions.js:1853. Server-side _profiles_match cross-aliases
'default'-tagged rows to a renamed root 'kinni'; the strict-equality client
would reject them, dropping every legacy session for renamed-root users. The
server is now solely authoritative for profile scoping.

SHOULD-FIX #2 (messaging-source dedupe ordering): _keep_latest_messaging_session_per_source
now runs AFTER the profile filter at api/routes.py:2078. Before, it ran on
the merged-cross-profile list with profile-blind keys, discarding the older
profile's row across profiles before the scope filter — leaving zero rows for
any messaging identity the active profile shared with another profile.

NIT #3: _projects_migrated flag now set only AFTER successful save_projects.
NIT #4: cleaned dead test code in test_is_root_profile_invalidation_drops_stale.
NIT #5: _create_profile_fallback's clone_from=='default' literal now routes
through _is_root_profile() for parity with the 5 other callsites.

+2 regression tests pin the SHOULD-FIX shapes:
- test_keep_latest_messaging_runs_after_profile_filter (source-string ordering)
- test_static_sessions_js_trusts_server_profile_scoping (no client re-filter)

4173 -> 4175 tests pass. 0 regressions.
2026-05-04 16:17:26 +00:00
Michael Lam b6c695e1ab test: cover provider config cleanup path 2026-05-04 09:04:07 -07:00
nesquena-hermes e8862632ed fix(profiles): scope sessions, projects, and root-profile resolution to active profile (#1611, #1612, #1614)
Closes #1611 — /api/sessions filters by active profile by default; ?all_profiles=1
opt-in for aggregate views; new _profiles_match() helper honours renamed-root
cross-aliasing; static/sessions.js drops the s.is_cli_session bypass; toggle-on
re-fetches with all_profiles=1 instead of slicing client-cached rows.

Closes #1612 — new _is_root_profile() central helper consults list_profiles_api()
for is_default=True matches alongside the legacy 'default' alias. Replaces five
literal-default callsites in api/profiles.py. Memoized with explicit invalidation
hooks at create + delete. Sticky active_profile file write now stores '' for
renamed root, consistent with the legacy empty==root contract.

Closes #1614 — projects carry a profile field stamped at create-time;
/api/projects filters by active profile; /api/projects/{create,rename,delete}
and /api/session/move reject ops on cross-profile projects with 404; new
_PROJECTS_MIGRATION migration in load_projects() back-tags untagged projects
from any session that uses them, fall back to 'default'; ensure_cron_project
keys lookup by (name, profile) so each profile gets its own Cron Jobs project.

31 regression tests (9+11+11) pin the renamed-root resolution, server-side
profile scoping shape, helper invariants, cross-alias matching, migration
behavior, and active-profile guards on every project mutation endpoint.
4148 tests pass.

Reporter: @stefanpieter

Co-authored-by: stefanpieter <noreply@github.com>
2026-05-04 16:03:05 +00:00
Frank Song 59efb42dcd Show Hermes Agent version in settings 2026-05-04 23:57:56 +08:00
nesquena-hermes 95200419ee Merge pull request #1626 from nesquena/stage-292
Release v0.50.292 — 12-PR batch (multi-tab SSE + subpath routes + 3 follow-ups + UX polish)
v0.50.292
2026-05-04 08:50:46 -07:00
Hermes Agent 1549a10510 chore(release): stamp v0.50.292 — 12-PR batch + Opus follow-ups absorbed
Constituent PRs:
  #1597 by @Michaelyklam — pytest config-path isolation
  #1598 by @Michaelyklam — multi-tab SSE broadcast (closes #1584)
  #1599 by @Sanjays2402 — _pending_started_at truthy-check (closes #1595)
  #1600 by @Michaelyklam — streaming markdown subpath/fallback
  #1601 by @Michaelyklam — subpath frontend routes
  #1602 by @ai-ag2026 — cross-source continuation
  #1603 by @ai-ag2026 — git remote name preservation
  #1605 by @ai-ag2026 — update banner branch labels
  #1608 by @franksong2702 — cron broad-except removal (closes #1578)
  #1609 by @franksong2702 — server.py socket cleanup (closes #1583)
  #1621 by @franksong2702 — fork indicator polish (fixes #1613)
  #1622 by @s905060 — paste text-with-image (closes #1620)

Opus advisor SHIP verdict + 2 SHOULD-FIX absorbed in-release:
  • #1598 ordering race fixed (offline-buffer replay moved inside lock)
  • #1601 sessions.js:1440 gateway SSE probe baseURI parity fix

4117 → 4142 tests passing.
2026-05-04 15:45:41 +00:00
test 06a71563de Stage 292: PR #1621 — polish forked session indicator by @franksong2702 2026-05-04 15:34:21 +00:00
test 21eb8a89bf Stage 292: PR #1598 — broadcast SSE stream events to multiple tabs (closes #1584) by @Michaelyklam 2026-05-04 15:34:17 +00:00
test 8a10532d29 Stage 292: PR #1601 — keep frontend routes under subpath mounts by @Michaelyklam 2026-05-04 15:34:08 +00:00
test 6f8424e5b7 Stage 292: PR #1622 — don't attach image on paste when clipboard has text (closes #1620) by @s905060 2026-05-04 15:33:32 +00:00
test b6702fbeae Stage 292: PR #1602 — keep cross-source continuations separate in sidebar by @ai-ag2026 2026-05-04 15:33:32 +00:00
test 51848fb67d Stage 292: PR #1603 — preserve git remote names in update links by @ai-ag2026 2026-05-04 15:33:32 +00:00
test 165356e744 Stage 292: PR #1608 — tighten worker-side broad-except in _run_cron_tracked (closes #1578) by @franksong2702 2026-05-04 15:33:32 +00:00
test 3985dadda6 Stage 292: PR #1609 — clean up dead socket code and fix macOS keepalive (closes #1583) by @franksong2702 2026-05-04 15:33:32 +00:00
test ead91878ef Stage 292: PR #1605 — show update branches in banner labels by @ai-ag2026 2026-05-04 15:33:32 +00:00
test e5a5720e00 Stage 292: PR #1600 — render streaming markdown on subpath mounts by @Michaelyklam 2026-05-04 15:33:32 +00:00
test 5b4ab72452 Stage 292: PR #1597 — isolate pytest Hermes config path by @Michaelyklam 2026-05-04 15:33:32 +00:00
test 38f9ece4f2 Stage 292: PR #1599 — streaming truthy-check for _pending_started_at fallback (closes #1595) by @Sanjays2402 2026-05-04 15:33:32 +00:00
Jash Lee 1ad0ab42e5 Fix #1620: don't attach image on paste when clipboard also has text
When the clipboard carries both text and an image (rich-text sources like
Notes, Word, Slack, browser selection attach a rendered preview alongside
the plain text), the paste handler in static/boot.js unconditionally
called e.preventDefault() and routed the image into addFiles(), silently
discarding the text payload.

Fix:
  - Detect text in the clipboard via items[].kind === 'string' &&
    (type === 'text/plain' || type === 'text/html'). When present, return
    early so the browser's default text-paste runs.
  - Tighten the image filter to kind === 'file' && type.startsWith('image/')
    so string items advertising an image MIME (e.g. text/html with an
    embedded data URI) are not misclassified as a true screenshot paste.

Pure-screenshot paste (image-only clipboard, e.g. Cmd+Shift+Ctrl+4 on macOS)
is unchanged.

Adds tests/test_1620_paste_text_with_image.py with 6 static-analysis checks
on the handler shape, matching the pattern of test_issue1095_pasted_images.py.
2026-05-04 10:48:36 -04:00
Frank Song 3f56ed7283 Polish forked session indicator 2026-05-04 21:50:40 +08:00
Frank Song 26208e46ae fix(server): clean up dead socket code and fix macOS keepalive (closes #1583)
- Delete QuietHTTPServer.server_bind() override entirely:
  TCP_KEEP* setsockopts on the listening socket are no-ops without
  SO_KEEPALIVE, and SO_REUSEADDR=1 is already set by the parent class.
  The actual fix lives entirely in Handler.setup().

- Restructure Handler.setup() with per-platform branches so
  SO_KEEPALIVE=1 is always applied before timing params, and macOS
  (TCP_KEEPALIVE) gets keepalive instead of aborting on TCP_KEEPIDLE.
2026-05-04 16:35:42 +08:00
Frank Song cdcd6021cc fix(cron): tighten worker-side broad-except in _run_cron_tracked (closes #1578)
Remove the try/except Exception wrapper around
cron_profile_context_for_home(...).__enter__() in _run_cron_tracked.
A silent fallback to ctx=None would leave the worker thread unpinned
against process-global HERMES_HOME, silently corrupting cross-profile
state — the same class of bug as #1573.

Add regression test to catch any future re-introduction.
2026-05-04 16:28:33 +08:00
Manfred 0b7f60a714 fix: show update branches in banner labels 2026-05-04 09:46:45 +02:00
Manfred 3c93d5a702 fix: keep cross-source continuations separate in sidebar 2026-05-04 09:30:47 +02:00
Manfred 93251e5bcb fix: preserve git remote names in update links 2026-05-04 09:30:47 +02:00
Michael Lam e9d7d5e427 fix: keep frontend routes under subpath mounts 2026-05-04 00:06:58 -07:00
Michael Lam 032b680e26 fix: render streaming markdown on subpath mounts 2026-05-03 23:55:45 -07:00
Sanjay Santhanam 14fac05dc9 fix(streaming): use truthy-check for _pending_started_at fallback
Switch the per-turn duration fallback from `is not None` to a truthy check so
None, missing-attr, and an explicit 0 all uniformly fall back to time.time().

Without this, a 0 timestamp (e.g. via a buggy migration or manual file edit)
would yield `time.time() - 0` ≈ wall-clock-since-epoch, displaying nonsense
like 'Done in 56 years 4 months ...'. In practice pending_started_at is always
set via int(time.time()) so this is a hardening fix, not a live-bug fix.

Also drop the brittle source-string assertion in the regression test that
pinned the literal expression. The behavioural test
test_done_handler_persists_duration_on_last_assistant_message already proves
the duration field is set; pinning the source line broke twice during the
v0.50.290 release pipeline alone (Opus tightening + maintainer revert).

Fixes #1595

Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
2026-05-03 23:21:19 -07:00
Michael Lam 22187d2b4c fix: resolve provider config cleanup path 2026-05-03 23:13:10 -07:00
Michael Lam ad46d82060 fix: isolate pytest Hermes config path 2026-05-03 22:47:55 -07:00
Michael Lam 6c5bc95b3b fix: broadcast SSE events to all tabs 2026-05-03 22:43:11 -07:00
nesquena-hermes 9986d2fd30 Merge pull request #1596 from nesquena/stage-291
Release v0.50.291 — 'What's new?' link 404 fix (closes #1579)
v0.50.291
2026-05-03 22:32:35 -07:00
test 7e8249e6f8 Stage 291: PR #1594 — 'What's new?' link 404 fix via merge-base (closes #1579) by @nesquena-hermes — APPROVED 2026-05-04 05:30:27 +00:00
nesquena-hermes 3369a08f37 fix(updates): use merge-base for compare URL so 'What's new?' link resolves
Closes #1579.

api/updates.py was building the GitHub compare URL from local HEAD short SHA:

    repoUrl + '/compare/' + curSha + '...' + newSha
    where curSha = `git rev-parse --short HEAD`

Whenever local HEAD diverges from upstream — unpushed work, dirty stage
branches, forks, in-flight rebases, release-time merge commits whose SHA
only lives in the maintainer's local history — the compare URL points at
a SHA github.com has never seen and returns the standard 404 page.

Reporter (@ai-ag2026) observed:
  https://github.com/nesquena/hermes-webui/compare/c660c7f...86cb22e
  → 404 because c660c7f was an unpushed local commit.

The right base is `git merge-base HEAD <compare_ref>` — the most recent
commit local and upstream share. Since `git fetch` succeeded just before,
the merge-base is guaranteed to exist on the upstream GitHub repo.

Behavior matrix:
  Pure-behind clone (no local commits): merge-base == HEAD; URL unchanged.
  Behind + local-only commits (#1579):  merge-base != HEAD; URL points at
                                        public ancestor instead of local HEAD.
  merge-base failure (shallow clone):   current_sha=None; JS link guard
                                        suppresses link rather than emitting
                                        a known-broken URL.

Also hardens static/ui.js: reset the link's href and display:none on every
banner render, so a stale link from a prior render can't survive a re-render
where the new payload has current_sha=null.

Tests:
  - test_current_sha_is_merge_base_not_local_HEAD — reporter's scenario
  - test_current_sha_equals_HEAD_when_no_local_commits — backward compat
  - test_current_sha_falls_back_to_None_when_merge_base_fails — defensive
  - test_whats_new_link_resets_display_and_href_on_every_render
  - test_whats_new_link_suppressed_when_curSha_falsy
  - test_reporter_url_shape_no_longer_produces_invalid_compare_url

4094 → 4100 passing. 0 regressions.
2026-05-04 05:26:19 +00:00