Commit graph

855 commits

Author SHA1 Message Date
nesquena-hermes
fe922d83b0 Merge remote-tracking branch 'origin/master' into stage-332
# Conflicts:
#	CHANGELOG.md
2026-05-10 18:07:50 +00:00
nesquena-hermes
22991fa820 Merge remote-tracking branch 'origin/master' into stage-331
# Conflicts:
#	CHANGELOG.md
2026-05-10 18:03:55 +00:00
nesquena-hermes
c9d4100218 Merge remote-tracking branch 'origin/master' into stage-332
# Conflicts:
#	CHANGELOG.md
2026-05-10 17:46:34 +00:00
nesquena-hermes
16535e1f66 Merge remote-tracking branch 'origin/master' into stage-331
# Conflicts:
#	CHANGELOG.md
2026-05-10 17:46:10 +00:00
nesquena-hermes
4f900d0763 Merge remote-tracking branch 'origin/master' into stage-330
# Conflicts:
#	CHANGELOG.md
#	static/i18n.js
2026-05-10 17:45:29 +00:00
nesquena-hermes
71aaad6e7f fix(stage-330): broaden chinese-locale test to accept both \uXXXX and literal CJK forms (PR #2002 source-form refresh) 2026-05-10 17:13:52 +00:00
nesquena-hermes
cb27ab0142 Stage 332: PR #2013 — fix(sessions): avoid sidebar jumps when active session is visible by @ai-ag2026 2026-05-10 17:09:44 +00:00
nesquena-hermes
2fb29e508c Stage 332: PR #2008 — fix(diff): CLI session patch diff rendering by @franksong2702 2026-05-10 17:09:44 +00:00
nesquena-hermes
f5a8a6f9ef Stage 332: PR #2007 — fix(mobile): wrap markdown code blocks on mobile by @insecurejezza 2026-05-10 17:09:44 +00:00
nesquena-hermes
c624770c63 Stage 331: PR #2015 — fix(sessions): stitch continued session transcripts by @Jellypowered 2026-05-10 17:09:21 +00:00
nesquena-hermes
44dc7d05e8 Stage 331: PR #2014 — fix(sessions): keep explicit fork sessions out of compression lineage by @ai-ag2026 2026-05-10 17:09:21 +00:00
nesquena-hermes
b68d7c62e7 Stage 331: PR #2012 — feat(sessions): read-only session lineage report endpoint by @dso2ng 2026-05-10 17:09:21 +00:00
nesquena-hermes
f4d3e9eed4 Stage 331: PR #2011 — fix(sessions): prefer latest compressed segment by @ai-ag2026 2026-05-10 17:09:21 +00:00
nesquena-hermes
9060bdb344 Stage 330: PR #2001 — fix(clarify): honor clarify.timeout config by @franksong2702 2026-05-10 17:07:37 +00:00
nesquena-hermes
9242305a81 fix(stage-329): zh-Hant locale parity for kanban_status_original_hint + extend locale parity test (Opus advisor SHIP-WITH-CAVEATS follow-up) 2026-05-10 17:06:10 +00:00
nesquena-hermes
f44ead1404 Stage 329: PR #1996 — test(kanban): modal locale parity regression by @franksong2702 2026-05-10 16:48:44 +00:00
nesquena-hermes
b01df72727 Stage 329: PR #1995 — feat(kanban): trap focus in kanban modals + status hint by @franksong2702 2026-05-10 16:48:41 +00:00
nesquena-hermes
7ce48de817 Stage 329: PR #1993 — fix(kanban): invalidate profile cache for assignee select by @franksong2702 2026-05-10 16:48:15 +00:00
nesquena-hermes
bf98ffec9b Stage 329: PR #1991 — fix(i18n): correct German profile_skill_count pluralization by @franksong2702 2026-05-10 16:48:15 +00:00
Jellypowered
8aed650b4c Stitch continued session transcripts in WebUI 2026-05-10 11:10:54 -05:00
ai-ag2026
017a631b6c fix: keep explicit fork sessions out of compression lineage 2026-05-10 18:03:21 +02:00
ai-ag2026
8226328cba fix: avoid sidebar jumps when active session is visible 2026-05-10 18:00:10 +02:00
Dennis Soong
c3cf8b10e9 feat: add read-only session lineage report 2026-05-10 23:28:14 +08:00
ai-ag2026
2a34a1256e fix: prefer latest compressed session segment 2026-05-10 17:04:33 +02:00
Frank Song
e64e02479f Fix CLI session patch diff rendering 2026-05-10 20:44:34 +08:00
insecurejezza
f7938372ba fix: wrap markdown code blocks on mobile 2026-05-10 19:12:20 +10:00
Frank Song
1bec8070f2 fix(1833): persist compression anchor summary for reload UI 2026-05-10 16:45:16 +08:00
Frank Song
2e6b3601bd fix(clarify): honor clarify.timeout config in webui prompts 2026-05-10 16:05:50 +08:00
Frank Song
ba51efec26 test(kanban): assert profile-cache invalidation on profile delete 2026-05-10 15:49:14 +08:00
Frank Song
2427f1e598 test(kanban): harden locale-block parsing for quoted locales 2026-05-10 15:48:18 +08:00
Frank Song
1e1a9481b4 fix(i18n): localize /goal runtime status strings 2026-05-10 15:21:24 +08:00
Frank Song
4c95d9274e test: add kanban modal locale parity regression 2026-05-10 15:03:21 +08:00
Frank Song
42a23818b3 Fix 1974: trap focus in kanban modals 2026-05-10 14:57:51 +08:00
Frank Song
b67d2676e4 fix(kanban): show original status hint in edit modal 2026-05-10 14:40:46 +08:00
Frank Song
b06eb99d91 fix(kanban): invalidate profile cache for assignee select 2026-05-10 14:37:37 +08:00
Frank Song
8f077d37f7 Fix German profile_skill_count interpolation 2026-05-10 14:25:08 +08:00
Frank Song
a0b757a9d4 Fix Kanban dispatch double-click race guard 2026-05-10 14:18:50 +08:00
Michael Lam
c7afae0e07 fix: lengthen auto-compression toast 2026-05-09 19:35:05 -07:00
nesquena-hermes
c67336e4e3 Stage 328: PR #1981 — feat(kanban): edit task button, real Run dispatcher, assignee dropdown by @nesquena-hermes
# Conflicts:
#	CHANGELOG.md
2026-05-09 21:02:27 +00:00
Nathan Esquenazi
8e0eedd163 fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
PR #1981's edit-task modal silently demotes tasks whose real status is
running/blocked/done/archived. The dropdown only offers triage/todo/ready,
so `_kanbanEditableStatusFor()` maps any other status to 'triage' for
display. If the user just edits the title and saves, the dropdown's
displayed 'triage' lands in the PATCH payload — and `_patch_task` calls
`_set_status_direct` which:
  - ends any active run with outcome='reclaimed' (worker yanked back)
  - nulls claim_lock / claim_expires / worker_pid
  - moves the task to triage

So editing a 'running' task's title would reclaim the running worker.
Editing a 'done' task would un-done it. Editing an 'archived' task would
un-archive it. All silent, no warning.

Reproducer (Node):
  Original: {status: 'running'}
  Modal display: 'triage' (mapped)
  User leaves dropdown alone → submit
  Payload: {title: 'X', status: 'triage'}  ← destructive

Fix: track the modal's initial displayed status in
_kanbanTaskModalInitialDisplayedStatus on edit-mode open. In submit's
edit branch, only include `status` in the PATCH payload when the user
actually picked a different value than what the dropdown opened with.
Create-mode resets the tracker to null so create payloads always include
status.

Verified end-to-end via Node harness:
  - edit running, untouched → no status sent ✓ (server keeps running)
  - edit running, picked ready → status:ready sent ✓ (worker reclaimed
    intentionally)
  - edit triage, untouched → no status sent ✓ (idempotent)
  - edit triage, picked ready → status:ready sent ✓
  - create new → status always sent ✓
  - edit done, untouched → no status sent ✓ (no un-done)

Adds test_kanban_edit_mode_preserves_status_when_dropdown_untouched
pinning the tracker variable, openKanbanEdit captures, submit-skip
condition, and create/close reset paths. Verified to fail pre-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:57:31 -07:00
nesquena-hermes
c71312b2e8 feat(kanban): edit task button, real Run dispatcher, assignee dropdown
Three connected gaps in the Kanban UX, fixed together because they're
load-bearing for the actual work-queue lifecycle:

1. Edit task — the detail view had only status-transition buttons (Triage/
   Todo/Ready/Blocked/Done/Archived) plus Block/Unblock and Add comment.
   No way to edit title, body, assignee, tenant, or priority once the task
   was created. Backend already supported it via PATCH /api/kanban/tasks/<id>
   (api/kanban_bridge.py::_patch_task) — purely a UI gap.

   Now: an Edit button on the task-detail header opens the existing modal
   pre-filled with current values, switches the modal title to 'Edit task'
   and the submit button to 'Save', PATCHes instead of POSTing on submit.

2. Run dispatcher — the existing 'Preview dispatcher' button always passed
   ?dry_run=1 (nudgeKanbanDispatcher), so it was preview-only. There was
   literally no UI button anywhere in the WebUI that actually ran the
   dispatcher to claim Ready tasks and spawn workers. Users had to drop
   to the CLI.

   Now: new runKanbanDispatcher() entry point hits /api/kanban/dispatch
   without dry_run=1, after a showConfirmDialog confirmation because it
   spawns subprocess workers. Two UI surfaces: a lightning-bolt button in
   the board header (visually distinct from the dry-run preview ▶), and
   a primary 'Run dispatcher' button in the sidebar bulk bar next to a
   relabeled 'Preview' button. Toast result shows concrete numbers from
   dispatch_once(): 'Dispatched: 1 spawned, 2 skipped (no assignee)' —
   not just a generic 'OK'.

3. Assignee dropdown — the previous create modal accepted free-text
   assignee with no validation. The dispatcher (kanban_db.py:3567) only
   spawns workers when row['assignee'] is a real Hermes profile name; any
   typo or blank value made the task sit in Ready forever.

   Now: <select> populated from /api/profiles (Hermes profile names) with
   historical board assignees grouped under 'Other (CLI lanes / removed
   profiles)', plus an explicit '— Unassigned (won't auto-run) —' option.
   Default selection is the first profile, not Unassigned. Custom SVG
   chevron so the field reads visually as a dropdown. Helper text under
   the field explains the dispatcher claim contract. Soft warning if user
   explicitly picks Unassigned + Ready ('You picked Unassigned + Ready.
   The dispatcher will skip this task. Submit again to confirm, or pick
   a profile.'); proceeds on second submit.

Side effect: default new-task status changed from triage to ready, since
'ready' is what users want for tasks they intend to actually run. Triage
is still in the dropdown for tasks that need staging review.

i18n: 19 new keys translated across all 8 supported locales.

Tests: 3 new regression tests in tests/test_kanban_ui_static.py:
- test_kanban_task_detail_has_edit_button_and_modal_supports_edit_mode
- test_kanban_assignee_dropdown_uses_select_not_freetext
- test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview

Verified end-to-end in browser: created board → opened modal with profile
dropdown → created task with assignee=archivist → clicked Edit → changed
all 5 fields → saved → verified persistence → clicked Run dispatcher →
confirm dialog → confirmed → toast 'Dispatched: 1 spawned' → task moved
Ready → Running.

Test suite: 5042 passed, 11 skipped, 3 xpassed, 0 regressions in 151s.
2026-05-09 20:48:28 +00:00
nesquena-hermes
ed776ee1a1
Merge pull request #1976 from nesquena/fix/mcp-profile-discovery
fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation (#1968)
2026-05-09 13:29:06 -07:00
nesquena-hermes
a3af4a3c8f fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation
Issue #1968: switching to a non-default profile in the WebUI dropdown
had no effect on which MCP servers were available. Every chat session,
regardless of profile, only saw the default profile's mcp_servers from
~/.hermes/config.yaml. Non-default profile MCP servers (postgres, custom
stdio servers, anything in <profile>/config.yaml) never registered.

Root cause: api/streaming.py:1922 called discover_mcp_tools() at the
TOP of _run_agent_streaming(), about 100 lines BEFORE the per-session
'os.environ["HERMES_HOME"] = _profile_home' mutation at line 2053.
discover_mcp_tools() reads ~/.hermes/config.yaml via get_hermes_home(),
which uses os.environ['HERMES_HOME']. So at the call site, HERMES_HOME
was still whatever the WebUI server process had at startup — the default
profile, every time.

Fix: relocate the discover_mcp_tools() call past the _ENV_LOCK block so
get_hermes_home() resolves to the session's actual profile home. Same
try/except wrapping is preserved; same idempotency semantics on
already-connected servers; same lazy-import pattern.

Caveat (out of scope, agent-side): _servers in tools/mcp_tool.py is a
process-global Dict[str, MCPServerTask] keyed only by server name. So
once profile A registers a server named e.g. 'postgres', profile B's
discovery sees 'postgres' as already connected and skips it — even if
B's config points at a different binary or DB. Concurrent multi-profile
WebUI processes will still hit 'first profile wins per server name'.
Fully fixing that requires keying _servers by (profile_home, name)
upstream in hermes-agent. This PR ships layer 1 only — fixes the
single-non-default-profile case (the headline symptom).

Tests: tests/test_issue1968_mcp_profile_discovery.py — 4 static tests
pinning the lexical ordering invariants. Verified mutation-safety: a
proof-of-concept revert (re-adding a discover call before the
HERMES_HOME mutation) makes the 'only called once' test fail.

Test suite: 5047 passed, 4 skipped, 3 xpassed, 0 regressions.

Closes #1968
2026-05-09 20:08:16 +00:00
nesquena-hermes
4ce113f324 Stage 327: PR #1965 — fix(kanban): header + button opens create-task modal (#1964) by @nesquena-hermes
# Conflicts:
#	CHANGELOG.md
2026-05-09 19:51:30 +00:00
nesquena-hermes
55623ef249 Stage 327: PR #1943 — feat: expand collapsed session lineage segments by @dso2ng 2026-05-09 19:50:50 +00:00
nesquena-hermes
10ea2a014f fix(kanban): header '+' button opens create-task modal
The Kanban sidebar panel's header '+' button (#kanbanNewTaskBtn) was
wired straight to createKanbanTask(), which reads the inline
#kanbanNewTaskTitle input and silently returns when empty. The inline
input lives below five rows of filters (search, assignee, tenant,
archived/mine toggles, stats, bulk-action bar) and is typically off-screen
on first panel open, so the header button looked dead — clicking it with
no title typed did nothing visible (no modal, no scroll, no focus shift,
no toast).

Now the header '+' opens #kanbanTaskModal — a centered overlay with the
same .kanban-modal-overlay shell the existing create-board modal uses,
so the two flows look and behave identically (centered card, dim
backdrop, ESC closes, click-on-backdrop closes). The modal exposes the
fields the backend already accepts at /api/kanban/tasks: Title, Description,
Status (Triage/Todo/Ready), Priority, Assignee (datalist suggestions from
the active board), Tenant (datalist).

UX details:
- Title is required; submit-with-empty shows a properly styled red error
- Title field auto-focuses on open
- ESC closes the modal; backdrop click closes; Enter on simple inputs
  submits, Enter in the description textarea inserts a newline
- Submit POSTs only the fields the user filled in (no forced empty strings)
  and auto-opens the new task's detail view
- Submit button disables while posting to prevent double-submit
- Inline quick-add (Enter on #kanbanNewTaskTitle) is preserved as a
  power-user shortcut

Side effect: .kanban-modal-error styling improved (proper red alert with
border + tinted background) so the existing create-board modal benefits
from the same polish for free.

i18n: 11 new keys added across all 8 supported locales (en, ja, ru, es,
de, zh, pt, ko).

Tests: tests/test_kanban_ui_static.py::test_kanban_new_task_header_button_opens_modal
covers the modal markup, button wiring, ESC/Enter handling, datalist
population, submit behavior, and inline-quick-add fallthrough.

Verified end-to-end in the browser on an isolated test env (port 8789):
created a board from scratch, opened the modal via header '+',
submitted with title/description/status/priority/assignee/tenant filled in,
moved the task through statuses (Triage → Todo → Ready → Blocked → Archived),
added a comment, verified Cancel + ESC + backdrop-click all close cleanly,
verified validation error rendering, verified inline quick-add still works.

Closes #1964
2026-05-09 19:33:07 +00:00
nesquena-hermes
57c71e89f3 fix(docker): salvage operational hardening from #1686 (env readonly + apt deps)
Three independent operational hardening fixes salvaged from PR #1686
(@binhpt310) after the parent PR was deferred over a separate sibling-repo
build-context concern unrelated to these fixes:

1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID,
   EGID, PPID) before `source`-ing.  docker-compose.yml's macOS instructions
   document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount
   permission fixing — that .env was crashing start.sh with
   `UID: readonly variable` when `set -a; source ...; set +a` tried to
   assign to those names.  Replaced with
   `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`.
   The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181
   still passes — both `source` and `.env` are still on the modified line.

2. start.sh now defensively re-execs as the unprivileged hermeswebui user
   when invoked as root.  Fires only when EUID==0 AND a hermeswebui user
   actually exists AND sudo is on PATH — so it's a no-op on host machines
   without the container user setup.  The production image's entrypoint
   (docker_init.bash) already drops to hermeswebui before invoking start.sh,
   so this is a no-op on the canonical container path; it only matters for
   `sudo ./start.sh` or accidental root shells inside the container during
   interactive debugging.

3. Dockerfile installs xz-utils + git apt packages.  xz-utils is required
   to decompress .tar.xz archives (e.g. Node.js distribution tarballs);
   git is needed for `git describe` (powers WEBUI_VERSION resolution at
   api/updates.py:_detect_webui_version) and any clone-based agent install
   path.  Both are tiny apt packages on top of python:3.12-slim with no
   measurable image-size impact.

What's NOT in this commit (deferred from #1686):

- Pre-baking hermes-agent source into the image via
  `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context
  flip to `..`.  Requires a sibling-repo layout that breaks the canonical
  `git clone hermes-webui && cd hermes-webui && docker compose build` flow.
  The right shape is a build arg gating the COPY behind
  --build-arg WITH_AGENT_SOURCE=1; left to a separate PR.
- Pre-installing Node.js 22 LTS system-wide.  Real motivation but worth
  evaluating the fix shape (full Node bake vs. opt-in vs. layer cache)
  separately from these three operational fixes.

Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep
on the start.sh filter pattern + 5 behavioral that actually run bash
against synthetic .env files containing readonly vars + 2 Dockerfile
package-presence tests).  All 11 pass.  Behavioral tests skip if bash
is not on PATH.

Full suite: 5028 → 5036 passing (+8 net new after pytest collection
counted some behavioral tests under skip), 0 regressions, 147.84s.

Closes the operational-hardening portion of #1686.

Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00
nesquena-hermes
8782fd2675 fix(stage-326): apply Opus advisor critical + recommended fixes
CRITICAL: #1951 PENDING_GOAL_CONTINUATION race
  Removes `PENDING_GOAL_CONTINUATION.discard(session_id)` from the
  streaming worker's `finally` cleanup block. The marker is set inside
  the SAME function call (line ~3328 on `goal_continue`) and the discard
  in the `finally` (line ~3553) almost always raced ahead of the
  frontend's SSE-receive → POST /api/chat/start round-trip, erasing
  the marker before the consumer in routes.py could read it. The
  consumer (`_start_chat_stream_for_session` in routes.py:6522) already
  discards atomically when consuming, so removing the streaming-side
  discard preserves single-use semantics and unblocks the
  goal-continuation chain.

  Adds tests/test_stage326_pending_goal_continuation_race.py with 5
  regression guards:
  1. streaming.py's finally must NOT discard PENDING_GOAL_CONTINUATION
  2. routes.py consumer must check + set + discard atomically
  3. PENDING_GOAL_CONTINUATION must be a set (GIL-safe single-op)
  4. STREAM_GOAL_RELATED.pop must be keyed by stream_id, not session_id
  5. PENDING_GOAL_CONTINUATION.add must precede the goal_continue SSE
     emission in source ordering

HARDENING: #1956 composer-draft input validation
  Per Opus, the POST /api/session/draft handler accepted unbounded /
  arbitrary-typed text and files inputs. With the 400ms debounced
  auto-save firing on every keystroke, a misbehaving client could
  persist multi-MB strings into the session JSON. Adds:
  - text: coerced to str if not already; clamped to 50_000 chars
  - files: coerced to list if not already; clamped to 50 entries
  Validation runs BEFORE the session lock acquire / save.

  Adds tests/test_stage326_composer_draft_validation.py with 5 guards.

Verdict from Opus advisor on stage-326: SHIP-WITH-FIXES.
This commit applies the required + recommended fixes; #1957 hardening
fixed in a prior stage commit.
2026-05-09 18:36:01 +00:00
nesquena-hermes
404e24ac9d fix(stage-326): preserve SESSION_TTL constant + reconcile #1957 tests
PR #1957 deleted the SESSION_TTL = 86400 * 30 module-level constant in
favor of the new _resolve_session_ttl() helper. Two existing regression
tests pin the constant: test_auth_sessions.TestSessionPruning.test_session_ttl_is_24_hours
imports SESSION_TTL directly, and test_v050258_opus_followups.test_redirect_session_ttl_30_days
asserts the literal "SESSION_TTL = 86400 * 30" line is present in source
(guarding against the daily-kick-out regression from #1419).

Restore SESSION_TTL as the named fallback for _resolve_session_ttl(); the
new env-var/settings.json path is unchanged. Backwards-compatible.

Also fix the new TestSessionTtlResolution suite:
- Switch from pytest's `monkeypatch` fixture (incompatible with
  unittest.TestCase subclasses) to setUp/tearDown env snapshotting
- Reconcile clamp tests with actual implementation: out-of-range env
  values fall through to settings/default, not snap to bounds
- test_session_uses_dynamic_ttl now sets the env var so the dynamic
  resolved value (3600s) is exercised rather than expecting the default

Verified: tests/test_auth_sessions.py + tests/test_v050258_opus_followups.py
21/21 pass.
2026-05-09 18:33:28 +00:00
nesquena-hermes
07d39612ce Stage 326: PR #1949 — fix(#1937): close endless-scroll prefetch vs Start-jump race with generation-token + mutex by @Sanjays2402
# Conflicts:
#	CHANGELOG.md
2026-05-09 18:17:51 +00:00