Overlays live pending_started_at/updated_at/last_message_at onto cached /api/sessions rows
(when newer), sorts active/running sessions first then by freshest effective timestamp, applies
the same running-first order in the frontend sidebar renderer, and groups date buckets with the
same runtime-aware timestamp (no stale header on resumed running sessions). routes.py +
sessions.js + 81-line test. Composes with #4680 cache-freeze + #4597 (98 session-list tests pass).
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
1-line CSS: adds touch-action:manipulation + -webkit-tap-highlight-color:transparent to
button/.icon-btn/.send-btn/.approval-btn/[onclick] inside the touch/compact media block
(@media max-width:1399.98px), removing iOS Safari's ~300ms double-tap-zoom delay so taps
fire on first touch. Added a regression test pinning the rule.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Module-level _compact_for_echo_compare (strips ALL whitespace, \s+ -> '') so _is_visible_output_echo's
endswith() suffix-match detects interim_assistant prose that only differs from visible token prose by
line-break/paragraph formatting. Hoisted to module scope (importable) + behavior-based test (real
token vs interim text equality). Codex SAFE (exact suffix match, not substring). 103 echo/journal tests.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Freezes the session-list cache source-stamp on the SET of active stream ids (not per-write
state) so mid-stream sidebar polls hold the cache instead of rebuilding all_sessions() every
poll and contending for the global LOCK the streaming worker holds (the ~2 tok/s + 15s
/api/sessions cause). Structural mutations still invalidate via publish_session_list_changed;
only a streaming session's own title/message_count lags <=TTL. routes.py + 109-line test.
99 session-list/sidebar/cache tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
When data-anchor-scene-live-owner=1, the #2403 legacy interim_assistant collapse toggle
('Show N earlier updates') is now cleared in renderLiveAnchorActivityScene cleanup AND the
interim_assistant SSE handler skips creating it. Guards both directions; regression test
locks the invariant. messages.js + ui.js. 289 interim/anchor/worklog tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Removes the unreliable _isVirtualKeyboardLikelyOpen() viewport-shrink heuristic (flaked on
iOS Safari + some Android where the keyboard doesn't shrink vv.height >120px). Mobile
Enter=newline now keys solely on matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()
— a more reliable software-keyboard-only signal. Hardware keyboards on tablets keep desktop
send behavior; Settings override preserved. Fn fully removed (no dangling refs). 2 new tests.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: neaucode-bot <neaucode-bot@users.noreply.github.com>
* stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master
REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot
diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master
(authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_
state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is
additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none;
does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off.
71 mobile-layout+swipe tests pass.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
* fix#4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior
* fix#4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener
Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed
interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for
taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller
under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch
listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open.
Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new
non-interactive design. 71 mobile tests pass.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Re-staged from the contributor's rebased head (0cee92c9) which independently converged on
the same 5-test-file class-tolerance fixups I'd made — shipping the contributor's exact
version, not my private variant. Tags md/markdown/mdx fenced blocks with md-source-block
class in both renderers; CSS wraps them while code/diff/csv keep horizontal-scroll. 90
renderer-family tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
test_tls_support.py E2E tests have flaked repeatedly across releases. Root cause:
_find_free_port() binds port 0, reads the port, CLOSES the socket, then returns —
leaving a TOCTOU window where the OS reassigns that port (common under the 9-shard
parallel suite + concurrent agents) before server.py binds it. Server fails to bind,
_wait_for_server polls a dead process for the full 8s, test times out.
Fix (test-only): new _start_and_wait() retries bring-up on a FRESHLY chosen port
(defeats the race), _wait_for_server bails the moment proc.poll() shows the subprocess
died (fast retry signal, not a slow timeout), timeout bumped 8s->30s for cold imports
under load, and server output is captured into the AssertionError on hard failure.
Verified deterministic: 4 consecutive clean runs + a run under 50-held-port contention.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
A failed /steer (or busy-mode steer) no longer silently falls back to interrupt+queue
(which cancelled the active stream). _trySteer now returns delivered:bool, restores the
draft + leaves the stream running on failure; send() only clears staged files when
_steerDelivered===true. Backend hunk comment-only (failure paths already returned
{accepted:False}, never touched CANCEL_FLAGS). i18n value-only on pre-existing keys
(locale-parity preserved, 173 locale tests pass). 48 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
#4584 (fixes#4295): scroll re-pin requires deliberate downward intent (deltaY<-30 unpin
threshold, re-pin only if not user-unpinned OR bottomDistance<=80, reset counter) so reading
mid-stream isn't hijacked back to bottom. ui.js only + 5 scroll-regression tests (4 existing
assertions adjusted, all green; new test_issue4295_scroll_pin_reentry).
#4583 (fixes#4553): mobile transcript overflow-x:hidden + max-width:100% + word-break so wide
content can't pan sideways. style.css only + own test. Disjoint files. 32 scroll-family tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Converged after deep bounce cycles: Finding A (production regression — skipped the
state.db source correction before the CLI filter, dropping WebUI sessions with stale-CLI
sidecar flags) FIXED via _apply_sidebar_state_db_overrides(result) before the routes.py
filter on both index + full-scan paths; Finding B (flaky lineage test) FIXED via hermetic
_persisted_session_ids_snapshot fixture. 16 own tests deterministic. Code byte-faithful
from PR head e1a3ecf22eca.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
_decorateTransparentEventRow populates the existing .tool-card-preview span from a
new quiet helper _transparentToolSummary(tc) — built from the call's TARGET
(path/command/query/skill via _toolVisibleTargetLabel), NOT the raw result JSON.
Restores the collapsed one-liner the #4411 preview-suppression dropped for the
transparent view (which overrides the row name back to the bare tool name, so it
had neither the action-label target nor a preview). Idempotent across status
re-decoration. Covers live + persisted/reload (shared render path). Code
byte-faithful from PR head ce7e5f394360. 5 own tests + 454 related pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Two-layer perf fix for the /api/reasoning storm dropping streaming to ~2 tok/s:
- ui.js: syncReasoningChip() serves the cached chip on routine topbar syncs, only
re-fetching when the model/provider key (_lastReasoningFetchKey) changes — restores
pre-a9ce2889 behavior while keeping its model-switch-refresh intent.
- config.py: memoize _load_yaml_config_file keyed on (path, st_mtime_ns, st_size) under
a lock; cache the RAW parsed dict and re-run cheap _expand_env_vars per call (cache stays
correct vs os.environ and read-modify-save callers get a fresh structure; on-disk edits
picked up via mtime/size). Code byte-faithful from PR head f868f10a1ad7. 9 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
In-chat cronjob tool reached cron.jobs whose path constants snapshot at import, so a
chat under a named profile hit the DEFAULT profile's cron dirs. Adds a per-turn
contextvar (_STREAMING_CRON_PROFILE_HOME, reset on every exit) + a tool-call-boundary
wrapper that enters cron_profile_context_for_home() only while the cronjob handler runs
(cron lock, restored immediately — no whole-turn global mutation). Verified: agent
executor's propagate_context_to_thread uses unfiltered contextvars.copy_context() so the
contextvar reaches the sync handler on the ThreadPool worker; reset-token symmetry intact.
Code byte-faithful from PR head c3fd6ab22f8c. 12 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: roian6 <roian6@users.noreply.github.com>
Fixes ~5s GET /api/sessions on power users (2400+ sessions) — _enrich_sidebar_lineage_metadata
probed state.db for every row's compression lineage. Caps to top-N (default 300, env
HERMES_WEBUI_LINEAGE_TOP_N, 0=disable). Display-safe: both call sites pass an already
pinned-first/newest-first sorted list, so sessions[:N] is exactly the paint-priority window.
Fail-open preserved. Added regression test (RED on master, GREEN here). Code byte-faithful
from PR head 016978578008.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: maksym-mishchenko <maksym-mishchenko@users.noreply.github.com>
Both Windows restart paths (api/updates._schedule_restart Update-button + bootstrap.py
supervisor auto-restart) now prefer pythonw.exe over python.exe and add CREATE_NO_WINDOW
to the Popen creationflags, so a restart no longer flashes an empty console window. Both
edits are inside sys.platform=='win32' guards (zero Linux/macOS change); bootstrap resolves
the win32-only flags via getattr(subprocess, attr, 0). Added regression test (RED on master,
GREEN here). Code byte-faithful from PR head 6d46e630e377.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: perejaslav <perejaslav@users.noreply.github.com>
#4644: close mobile sidebar drawer after a rail-click panel switch (was left open).
#4645: animate drawer open/close with transform:translateX instead of left offset.
Disjoint product files (panels.js vs style.css); both add tests to test_mobile_layout.py
in distinct functions (no conflict). Mobile-only, desktop unaffected. 60 mobile-layout
tests pass. Code byte-faithful from PR heads.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
UI enable/disable of installed manifest extensions via a bounded fail-safe
extension-overrides.json state file. Safe boundary: no manifest edits, no asset
fetches, no sidecar proxy, no extension-owned routes. /api/extensions/toggle is
auth+CSRF gated; anchored _EXTENSION_ID_RE (no path traversal); atomic UTF-8 write
under _EXTENSION_STATE_LOCK; 32KiB/512-id DoS bounds; disabled override suppresses
asset/sidecar injection end-to-end. Nathan greenlit the extensions-phase-2 concept.
Code byte-faithful from PR head 8769b4a3902e. 49 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
The ruff E9 gate is diff-scoped, so a SyntaxError/IndentationError reported on
an untouched line can slip the filter and only surface as a mass-failure of
every test shard at the conftest server-boot fixture. compileall fails in ~2s
with a clear file:line, before the matrix suite runs. Python analog of the JS
node --check pass. Prompted by #4641.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* stage #4620 (franksong2702): snapshot isolated-profile opt-in at import (fixes#4590)
Security/deployment-hardening follow-up to #4586/#4589. Snapshots
HERMES_WEBUI_ISOLATED_PROFILE at module import (_INITIAL_ISOLATED_PROFILE_OPT_IN)
so _isolated_profile_opt_in() reads the startup snapshot, not live os.environ — a
pinned profile .env loaded later can't flip the isolation posture. _reload_dotenv
key filter kept as defense-in-depth. Adds one-time warning when HERMES_HOME has a
profile-dir shape but isolated wasn't opted in at startup. Code byte-faithful from
PR head 5ef14264.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
* Release v0.51.566 — Release TY (isolated-profile opt-in hardening; #4620)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
* stage #4612 (santastabber): surface loopback sidecar diagnostics + CHANGELOG
Settings -> Extensions surfaces manifest-declared loopback sidecar companion services
(type:loopback, http(s) on 127.0.0.1/localhost/::1 only, sanitized origin+health_path)
as read-only health cards w/ a browser-side credentials-omitted health probe. Backend
hard-whitelists scheme + loopback host, rejects userinfo/path/query/traversal, rebuilds
output from parsed components (no echo of rejected input); frontend esc()'s every field,
whitelists badge status, fetch never reads body. Purely additive to GET /api/extensions/status.
Code applied byte-faithful from PR head 7080929f3727. 33 own tests pass.
Nathan concept-approved (loopback-companion direction).
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
* fix#4612 Codex gate findings: reject encoded query/fragment in health_path + align CSP loopback allowlist
Codex SHIP-WITH-FIXES (2 reproduced SILENT findings):
1. _normalize_sidecar_health_path percent-decoded AFTER the raw query/fragment ban,
so /health%3Ftoken=abc -> ?token=abc survived into the probed URL. Now re-rejects
decoded ? and # + regression test (%3F/%23 skipped with sidecar_health_path_rejected).
2. The origin validator accepts https:// and [::1], but CSP connect-src only listed
http 127.0.0.1/localhost -> accepted https/IPv6 loopback sidecars would be CSP-blocked.
Aligned _CSP_CONNECT_BASE (added https v4/localhost + http(s) [::1]) + updated the 4
exact-string CSP tests + a regression test asserting all loopback forms are allowed.
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
* Release v0.51.564 — Release TW (loopback sidecar diagnostics; #4612)
* fix(#4612): drop browser-invalid [::1]:* CSP source (browser-smoke fail)
The CSP-loopback-alignment fix added http(s)://[::1]:* to connect-src, but CSP
host-source grammar can't express a port wildcard on a bracketed IPv6 literal —
Chromium rejects '[::1]:*' as an invalid source, which browser-smoke flags as a
console error (CI fail). Keep the valid https://127.0.0.1:*/localhost:* additions;
drop the IPv6 wildcard entries. IPv6 [::1] sidecars are still accepted by the
validator (displayed in the card); their browser health probe surfaces as
'blocked' under CSP unless an operator adds a specific-port [::1]:<port> via
HERMES_WEBUI_CSP_CONNECT_EXTRA. Tests updated to match + assert [::1]:* absent.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
* stage #4380 (santastabber): render selected context as quote cards + CHANGELOG
Evolves the shipped #2543 named-context-blocks: composer chips -> quote cards
(accent rail, editable label w/ rename, remove, clipped excerpt) + thread render
of labeled selections as <figure> cards. Label+quote esc()'d on every path
(adversarial harness 0 survivors). 3 new i18n keys in all 13 locales (parity green).
Code applied from PR head 2eefc11c02a7 (own 3-dot contribution, CHANGELOG re-stamped).
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
* fix#4380 Codex gate finding: selection-only reply must enable the primary Send button
Codex CORE: #4380 moved selected context out of the textarea into _pendingSelections,
but _composerHasContent() only checked textarea+files -> getComposerPrimaryAction()
returned disabled for a selection-only reply (desktop Enter calls send() directly so
it worked, but click/tap/mobile users were blocked). Fix: expose
window._hasPendingSelections() from messages.js, fold it into _composerHasContent(),
and call updateSendBtn() from _renderSelectionChips() so add/remove/clear refreshes
the button. + regression test.
* Release v0.51.562 — Release TU (selected-context quote cards; #4380)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
* stage #4618 (allenliang2022): broaden streaming stale-compressor guard to any model-window mismatch + #4618 regression tests + CHANGELOG
Broadens #3256's default-only live-usage guard: the streaming SSE snapshot now
always resolves the real per-model window via the same helper GET /api/session
hydration uses (_context_length_lookup_inputs_for_model + get_model_context_length)
and corrects whenever it differs from the compressor's cached value, with a
TypeError fallback to the legacy 2-arg form. Fixes 'refresh shows 1M, send reverts
to stale 168k + early auto-compress' on model-switched sessions. Per-stream cache
preserved (one lookup/stream). Code byte-identical to PR head 3beb18e92d73.
Adds 4 source-structure regression tests (RED-proven on master).
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
* fix#4618 gate findings: profile-scoped config (Codex cross-profile) + #4248 256k acceptance gate (Opus downward-clobber)
Codex SHIP-WITH-FIXES: live-snapshot used ambient get_config() which in the
detached streaming worker resolves the process-global/default profile (#3294) ->
for a non-default profile pinning a different per-model context_length it would
surface the WRONG profile's window. Now resolves via get_config_for_profile_home
on the session's own profile home (mirrors the worker's _cfg resolution).
Opus SHIP-WITH-FIXES: broadened guard aligned resolution w/ hydration but not its
#4248 acceptance gate -> a transient low-confidence 256k metadata probe could
clobber a LARGER cached window mid-stream. Now reuses the exact hydration helper
_should_accept_session_context_length_refresh on both modern + legacy paths.
+ regression tests for both. Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
* fix#4618 Codex re-gate findings: broaden save + SSE-done stale-compressor guards too
Codex re-gate found the broadened live-snapshot guard fixed metering but the two
SIBLING paths still used the old default-only exact-cap test:
- api/streaming.py final session-save: persisted stale other-model window (168k)
to s.context_length -> wrong window on reload.
- api/streaming.py terminal SSE: emitted stale window -> indicator REVERTS
on stream end (messages.js overwrites S.lastUsage) = the exact 'send reverts to
168k' symptom.
Both now resolve the real per-model window via the same hydration helper and honor
the #4248 acceptance gate (no 256k downward-clobber), with legacy 2-arg fallback.
This is the root-cause completion across all 3 paths (live/save/SSE-done).
+ 2 regression tests. Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
* docs(#4618): note model_changed-omission rationale (Opus) + broaden CHANGELOG to all-3-paths
* Release v0.51.561 — Release TT (context-window indicator stays correct after model switch; #4618)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Behavioural regression guard for the Transparent Stream / Worklog reload path.
Drives the real buildToolCard + _anchorSceneToolCallFromRow from static/ui.js
via node and pins that a tool card reconstructed from a persisted
activity_scene_v1 row keeps:
- the full terminal output with a working Show-more expand (data-full), and
- the diff body (diff-block) for patch/edit snippets.
#4622 was reported as a regression after v0.51.547-.560, but that window
RESTORED this activity (#4539/#4587); independent repro on master was negative.
This is the permanent guard so the restore path can't silently lose full
output / expand / diff again. Test-only; no product change.
RED-proven: dropping the payload.result/output recovery in
_anchorSceneToolCallFromRow makes the two restored-card tests fail.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Install-time-only bootstrap fix: discover agent installed as root (FHS /usr/local/lib/hermes-agent) or behind the bash launcher. Independently reviewed + approved (nesquena, OWNER); CI green on all 9 shards + browser-smoke + lint. No version bump (no runtime/agent change).
A fresh root install on Linux (e.g. a new Proxmox/Ubuntu VM) places the Hermes
Agent at /usr/local/lib/hermes-agent and links the CLI into /usr/local/bin,
while the modern `hermes` launcher is a `#!/usr/bin/env bash` wrapper that
exec's the venv entrypoint. The old discovery only checked
~/.hermes/hermes-agent-style paths and parsed the launcher's shebang as a Python
interpreter, so on a root VM discover_agent_dir() returned None, bootstrap built
a WebUI-only .venv, and chat aborted at launch with "Python environment cannot
import both WebUI dependencies and Hermes Agent."
Two complementary fixes:
- Add /usr/local/lib/hermes-agent to discover_agent_dir()'s candidate list.
- Harden _agent_dir_from_hermes_cli() to follow the launcher's exec target (any
absolute path quoted in the wrapper) up to run_agent.py, not just a Python
shebang. Skips /usr/bin/env indirection.
Now `python3 bootstrap.py` works out of the box for root/container installs with
no HERMES_WEBUI_PYTHON override needed. Adds tests for the bash-wrapper shape,
the env-indirection no-false-positive case, and the FHS candidate probe; updates
docs/troubleshooting.md.
allenliang2022: recovered assistant rows were added to the visible transcript but not context_messages,
so the model lost recovered output. Adds _append_recovered_turn_to_context with a role-dedup guard.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
The repo enforces every locale defines every English key (test_*_locale.py). Adding the key to
English only broke 6 locale-coverage tests. Added the key (English fallback text) to all 13 locale
blocks. Full suite catches what targeted tests miss.
Codex gate (SILENT): panels.js referenced t('cron_model_no_agent_hint') but the key was never added
to i18n.js. t() returns the key name for a missing translation (falls back to LOCALES.en, then the
key), so script-only cron jobs rendered the literal 'cron_model_no_agent_hint'. Added the English key
(other locales fall back to en) + strengthened the i18n-keys test to assert the value is defined.
Codex gate (SILENT): the new outside-symlink info dialog uses showConfirmDialog({hideCancel:true})
which sets the shared #appDialogCancel to display:none; showPromptDialog only reset textContent, so a
later rename/new-file prompt opened with no Cancel button. Fix: showPromptDialog now restores
cancelBtn.style.display='' first. + regression test.
Codex+Opus gate (info-leak): the display-only escape-target rows still returned the resolved
outside path (target), target-derived is_dir, and target stat size; ui.js showed the outside path
in the open-dialog. Fix: emit ONLY name/path/type/target_outside_workspace/mtime for outside rows
(no target, no size, is_dir=False); dialog uses a generic 'points outside workspace' message (dropped
{target} from external_link_open_confirm across all 13 locales). Containment was already intact (both
gates confirmed); this closes the path-disclosure. Tests updated to assert the no-leak property.
claw-io: symlinks whose target resolves outside the workspace root were silently dropped; now emitted
with target_outside_workspace=True (display-only — safe_resolve_ws/open_anchored_fd still block
navigation). Dedup-winner over #4573 (stronger tests). Co-authored-by: claw-io <claw-io@users.noreply.github.com>