macOS Finder's 'Copy as Pathname' (Cmd+Option+C) wraps paths in single
quotes by default — '/Users/x/Documents/foo' — and users routinely paste
those quoted strings into the Add Space input expecting them to work.
Other shells and OS file managers do similar things with double quotes.
Today the path is taken via .strip() only, so the literal quote
characters become part of the resolved Path and the validator rejects
the result as 'not a directory'. cygnus reported this on Discord
(2026-05-01) — she had to manually un-quote her paths to register a
new Space.
Fix:
- New api.workspace._strip_surrounding_quotes() helper. Removes only
the outermost paired single or double quotes; preserves unpaired or
mismatched quotes (a path may legitimately contain a literal quote).
- validate_workspace_to_add() calls it before resolution so every
code path that registers a workspace benefits, not just the HTTP
route.
- _handle_workspace_add() also calls it at the route entry so the
blocked-system-path check and the duplicate-detection check both
see the cleaned form.
14 regression tests pin the behavior matrix:
- Unwrapped path unchanged
- Single quotes stripped
- Double quotes stripped
- Whitespace outside quotes handled (trim-then-strip)
- Only outermost pair removed (internal quotes preserved)
- Unpaired / mismatched quotes preserved
- Empty string + just-a-pair edge cases
- Validate_workspace_to_add accepts quoted form for existing dir
4610 tests pass (+14 from this PR), 0 regressions, ~2:27 full suite.
Reported by Cygnus on Discord, May 1 2026.
The Mac Swift app (hermes-webui/hermes-swift-mac) and any other native
WKWebView wrapper need the active theme background to keep AppKit
chrome (tab bar, title bar, traffic-light area) in sync with the page.
The current Mac approach pixel-samples the page via
elementsFromPoint, which is fragile against modals/lightboxes/file-tree
overlays — any opaque overlay over a sample point can poison the
chrome colour for the entire app. (See swift-mac issue #70.)
Surface the active theme's background as the canonical, overlay-resistant
source of truth via <meta name="theme-color">:
- Two static prefers-color-scheme variants in <head> for browsers that
read theme-color before any JS runs (mobile Safari, PWAs).
- One id="hermes-theme-color" runtime tag with an inline pre-paint
seed script that reads localStorage hermes-theme so the meta tag
is correct on first paint, before boot.js loads.
- New _syncThemeColorMeta() helper in static/boot.js that reads
getComputedStyle(html).getPropertyValue('--bg') and writes it into
the runtime meta tag. Called from _setResolvedTheme (both branches —
prism-loaded and prism-absent) and from _applySkin so every theme
toggle and skin switch updates the meta tag.
Reading --bg via getComputedStyle means each skin (Default, Sienna,
Sisyphus, Charizard, etc.) reaches the meta tag with its distinct
background — no per-skin lookup table to drift.
Browser-verified end to end on port 8789:
- light + default → meta=#FEFCF7 (matches --bg)
- light + Sienna → meta=#FAF9F5 (skin's distinct bg)
- dark + Sienna → meta=#1F1E1C (skin's dark variant)
10 regression tests added in tests/test_theme_color_meta_bridge.py
covering: static media variants present, runtime id stable, pre-paint
seed reads localStorage, helper defined and reads computed --bg,
helper targets known id, both _setResolvedTheme branches call sync,
_applySkin calls sync, root --bg defaults still match.
Companion PR coming on hermes-webui/hermes-swift-mac to switch the
theme bridge from elementsFromPoint pixel-sampling to reading
document.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').content.
Refs hermes-webui/hermes-swift-mac#70.
PR #1742 removed the only consumer of the `profiles_busy_switch` toast
(the frontend S.busy-based early return in static/panels.js — which was
shown when profile switch was blocked by an active stream). The 9 locale
entries are now orphaned: they exist in static/i18n.js but no code path
references them.
Opus stage-304 advisor flagged this as a low-priority SHOULD-FIX
("file as a v0.51.x cleanup ticket, don't block the release"). Absorb-
in-release per the absorb-default policy: ≤10 LOC and clearly defensive.
Removed entries: en, ja, ru, fr, de, zh, zh-Hant, pt, es. Locale parity
tests still pass (no key is missing; we removed it from English first).
4596 tests still pass.
Opus advisor on stage-303 (#1738 verification Q4) flagged that the
catalog-coverage branch produces a redundant repair-write per chat-start
when the active Codex default is itself slash-prefixed: the repair sets
`provider_context = None`, the next chat-start hits the same branch
because `requested_provider is None` again, and the repair fires repeatedly.
In practice Codex `default_model` is always a bare `gpt-...` ID from the
Codex catalog, so this is theoretical. But once we've decided this session
belongs to Codex, we should persist that decision. Drop the conditional
catalog-coverage check and unconditionally attach `raw_active_provider`
("openai-codex") on this repair path. The shape is now stable across
resolutions.
Absorb-in-release per Opus stage-303 verdict — small, defensive, ≤10 LOC.
Root page loads should not automatically project a localStorage-saved running session into the active pane. Keep explicit /session/<sid> behavior unchanged while leaving the saved session discoverable from the sidebar.
(cherry picked from commit bb60cf21d911a84e285363bcecf46fb441181fb9)
Opus advisor on stage-302 (#1732 verification Q5) flagged that
_lastScrollTop is module-global and persists across chat switches. When
the user switches sessions, the new chat's first user scroll compares
against the previous chat's last scrollTop. If the previous was deep-
scrolled (e.g. 5000) and the new chat starts at top=0, scrolling down
to 100 would evaluate as movedUp=true → false-unpin, blocking auto-
scroll on the new chat's first incoming token.
Fix: expose _resetScrollDirectionTracker() from static/ui.js on window
so static/sessions.js loadSession() can reset _lastScrollTop=null when
S.session is reassigned. The scroll listener's existing _lastScrollTop!==null
guard then handles the first sample after reset correctly (no false-trigger
on the very first scroll event in the new chat).
Absorb-in-release per Opus stage-302 verdict — small, defensive, ≤20 LOC.
PR #1728's path/mtime-aware get_config() reload broke the common test
idiom monkeypatch.setattr(config, 'cfg', {...}). The cfg = _cfg_cache
alias bound at import time means the rebinding only changes the module
attribute; _cfg_cache stays unchanged, so _cfg_has_in_memory_overrides()
returned False and the path-aware reload silently overwrote the test's
override. test_issue1426_openrouter_* and test_issue1680_codex_* failed
in the full suite while passing standalone — exact polluter signature.
Fix:
- _cfg_has_in_memory_overrides() now also detects cfg-rebind via
cfg is not _cfg_cache.
- get_config() returns cfg (the override) when it differs from
_cfg_cache, so callers see the test's intended override.
- 4 new regression tests pin both prongs in
test_stage302_config_override_regression.py.
Defense-in-depth (prong 2 of test-isolation-flake-recipe):
- test_sprint3.py::test_skills_list and test_skills_list_has_required_fields
now skip on empty skills list rather than asserting > 0 / IndexError, so
future profile-switch / SKILLS_DIR repointing pollutions don't break
the build. The contract under test is 'API returns a non-empty list
when there are entries' — empty list signals a polluter elsewhere.
Pre-existing wall-clock flake fix (absorb-in-release):
- test_issue1144_session_time_sync.py::test_relative_time_uses_server_clock
now pins Date.now() to a fixed instant. Without pinning, when CI runs
near 08:00 UTC the projected server time crosses midnight and '5 minutes
ago' silently becomes '1d'. Same time-of-day-pin pattern as the sibling
test_session_bucket_uses_server_clock used.
Test count: 4580 → 4584 (+4 regression tests). 0 failures, stably green
across multiple runs.
The streaming scroll listener applied hysteresis symmetrically: an
upward scroll that landed inside the 250px near-bottom dead zone still
reported the user as near the bottom, so _nearBottomCount kept
incrementing and _scrollPinned stayed true. The next streaming token
snapped the user back to the bottom. The user effectively had to escape
the 250px zone in one fling to read earlier output.
The 250px dead zone itself is required by #1360 / #677 (macOS small
window + trackpad momentum re-pin protection) so the fix is direction
detection, not threshold relaxation: track _lastScrollTop and unpin
immediately on an explicit upward movement (>2px decrease), while
downward / stationary movement keeps the original hysteresis re-pin
path so the macOS momentum protection is preserved.
Programmatic scrolls are still masked by the existing _programmaticScroll
guard, so scrollToBottom() never updates _lastScrollTop and never
spuriously unpins.
Adds tests/test_issue1731_upward_scroll_unpins.py covering: direction
tracker exists, upward branch sets _scrollPinned=false and resets the
counter without hysteresis, downward branch preserves the >=2
hysteresis re-pin requirement, the 250px threshold remains, and the
_programmaticScroll bail still runs before the rAF schedule.
Closes#1731.
Co-Authored-By: Potato (OpenClaw assistant) <noreply@openclaw.ai>
Closes#1695.
@Patrick-81 reported the bare "AIAgent not available -- check that
hermes-agent is on sys.path" error on a symlinked install (~/Programmes/hermes-agent
linked to ~/hermes-agent). The maintainer's response — three diagnostic
commands plus `pip install -e .` in the agent dir — fixed it for them.
This PR captures both halves of that learning so the next user with the
same shape doesn't have to file a new issue:
1. **Error message diagnostic block.** New helper
`_aiagent_import_error_detail()` in api/streaming.py builds a multi-line
diagnostic when the import fails, including:
- the running Python interpreter
- HERMES_WEBUI_AGENT_DIR (set value, or "(not set)")
- sys.path entries that mention hermes/agent (or "no entries mention..."
— itself a strong diagnostic signal)
- the most-common fix (`pip install -e .` in the agent dir)
- a pointer to docs/troubleshooting.md
The original error message string is preserved as the FIRST line so
existing log scrapers and docs-search keep matching.
Helper is kept as a separate function so it stays out of the hot path
until we actually need to raise — building it on every successful import
would be wasted work.
2. **New docs/troubleshooting.md.** Symptom → Why → Diagnostic commands →
Fix → When-to-file-a-bug template, with one entry to start: the
"AIAgent not available" flow Patrick-81 walked through. Future
recurring failure modes follow the same template. Required a one-line
addition to .gitignore — docs/* is gitignored with an allowlist, and
the new file needed `!docs/troubleshooting.md` to be tracked.
3. **README link.** docs/troubleshooting.md added to the `## Docs` section
so users know where to look first.
13 regression tests in tests/test_1695_aiagent_import_error_detail.py:
9 for the helper output shape (preserves original message line, includes
running python, shows HERMES_WEBUI_AGENT_DIR set/unset both ways, includes
pip-install-e hint, points at troubleshooting doc, lists relevant sys.path
entries when present, says "no entries..." when absent, output is multi-line)
plus 4 for the docs-presence regression (file exists, has the AIAgent
section, includes pip install -e ., describes the diagnostic chain with
readlink + agent/__init__.py verification).
190 streaming/aiagent tests pass after the change. ast.parse on
api/streaming.py clean.
CI failure on prior push was due to the docs/* gitignore swallowing the
new troubleshooting.md file silently — this commit adds the allowlist
entry so the file is tracked.
Hide preserved compression task lists when the latest todo tool state
shows no pending or in-progress items. This prevents completed tasks from
reappearing after reloads or context compaction.
Tests: uv run --with pytest --with pyyaml python -m pytest -q tests/test_auto_compression_card.py
Tests: node --check static/ui.js