18 Commits
Author SHA1 Message Date
Sanjay Santhanam 92bddca3c7 fix: file manager falls back to state.db for external Telegram/CLI sessions (#3280) 2026-06-01 17:19:48 +00:00
Sanjays2402 ab4de3bb64 Fix #3103: stop emitting Connection: close on long-lived SSE streams
The gateway SSE handler (`_handle_gateway_sse_stream`) and session-events
SSE handler (`_handle_session_events_stream`) both set
`Connection: close` on the response. On long-lived SSE streams, browsers
(Chrome, Firefox, Safari) interpret that header as 'this is a one-shot
response, the EventSource lifecycle is over the moment the body ends'
and trigger an immediate reconnect when the server-side worker rotates.

The result, with EventSource's auto-reconnect on top, is a tight loop of
connect -> sessions_changed snapshot -> reconnect every ~1s that pegs
the worker, thrashes the session list, and prevents the in-app session
list from ever settling.

Removing the header lets the python BaseHTTPServer close the socket
naturally after the stream ends, which is what EventSource expects.

Regression introduced in 598fd4ff.
2026-05-30 02:02:40 +00:00
Sanjays2402 01c27806d4 Fix #3018: preserve per-turn ephemeral fields across session refreshes
`_turnUsage` (and `_turnDuration`, `_turnTps`, `_gatewayRouting`,
`_statusCard`) are computed client-side in `_finishDone()` and attached
to the last assistant message in `S.messages`. Three code paths replace
`S.messages` wholesale with fresh API data that lacks these fields:

  1. `_restoreSettledSession()` after a late `stream_end` or SSE error.
  2. The late-restore branch after `done` (messages.js ~L2247).
  3. `loadSession()` for active-session external refresh / focus-change.

When any of these fire after `done`, the badge/duration/gateway-pill
flashes for ~1s and disappears, never returning until the next message
or page reload.

Add `_carryForwardEphemeralTurnFields(prev, next)` that matches messages
by `(role, timestamp, content prefix)` — the same identity the renderer
already uses — and copies forward the ephemeral fields when the server
payload is missing them. Wire it into all three replace sites. The fix
is conservative: it only fills slots that are `null` on the new message,
so an authoritative server-side value (if/when the API ever surfaces
per-turn usage) wins automatically.

Picked Option A from the bug report (preserve on the client side) over
Option B (synthesize from `S.lastUsage`) because `lastUsage` is a
session-level aggregate; reconstructing per-turn breakdowns from it is
lossy. Option C (set `_streamFinalized` earlier) would suppress legit
late-arriving server data on transient errors.
2026-05-30 00:11:50 +00:00
Sanjays2402 24b175045f Fix #3114: keep jump-to-question button on intermediate assistant messages
In multi-step turns (assistant -> tool_call -> assistant -> tool_call ->
final assistant), only the turn-final assistant bubble was rendering the
'jump to question' navigation button because the gate keyed on
isTurnFinalAssistant. Intermediate assistant bubbles that *do* have a
resolvable question raw-index lost the affordance entirely.

Switch the gate to 'show whenever questionRawIdxByAssistantRawIdx has a
target for this rawIdx', which is the actual precondition for the button
being meaningful. Turn-finality was a proxy for 'has a question target'
that under-covered multi-step turns.

No template/CSS change needed; _questionJumpButtonHtml already handles
the rawIdx-or-undefined contract.
2026-05-30 00:09:13 +00:00
Sanjays2402 e09c6aa6bd Fix #3076: don't force mobile send-on-Enter when a real keyboard is attached
A touch-primary device (`matchMedia('(pointer:coarse)')` is true) can
still have a physical keyboard available — Android tablet + Bluetooth
keyboard, detachable Surface, iPad + Magic Keyboard. The existing
`_mobileDefault` gate flipped Enter to newline on every such device the
moment the visual-viewport heuristic *thought* the soft keyboard was
open, which it often did when the on-screen IME hadn't actually come up
because the user is typing on the hardware keys. Result: Shift+Enter and
Ctrl+Enter never sent and the user could not submit at all.

Add `_hasFinePointerCoexisting()` (`(any-pointer:fine)`) and short-
circuit the mobile-default path when ANY fine pointer is present. That
flag is true whenever a real mouse/trackpad/stylus is paired, which is
the strongest browser signal we have for 'there is a hardware input rig
in the picture too'. Pure-touch phones/tablets are unaffected.
2026-05-29 03:26:12 -07:00
Sanjays2402 7c3d85d6c5 Fix #3107: throttle active-session external-refresh poll from 5s to 30s
The active-session 'is it externally updated?' fallback poll fires every
5 s. On long sessions this causes visible scroll churn (the rendered
message list is rebuilt and the scrollTop is restored on a 5-second
cadence) and a measurable network/CPU floor even when the user is just
reading.

This poll is a *fallback* for the case where the SSE session-events
stream is unavailable; SSE already invalidates the active session in
real time. Pushing it to 30 s keeps the safety net for SSE-broken
environments without it acting as a primary refresh path.

Regression introduced in 467ef33a.
2026-05-29 03:25:49 -07:00
Sanjay Santhanam cf94f2f7c4 docs(docker): promote scheduled-jobs section to H2 + runnable verify command (review feedback from @nesquena-hermes) 2026-05-25 23:11:59 -07:00
Sanjay Santhanam 0211e1e012 fix(providers): pass through server CSRF reason instead of one-size message (review feedback from @nesquena-hermes) 2026-05-25 23:11:11 -07:00
Sanjay Santhanam 6dd6bced67 fix(transcript): preserve subagent accent over tool-card border-left (review feedback from @nesquena-hermes) 2026-05-25 23:10:39 -07:00
Sanjay Santhanam 655b5f9101 docs(docker): point gateway-not-configured banner at a real fix
Scheduled cron jobs created in the Tasks panel never tick on a
single-container Docker install because the WebUI doesn't run the
gateway daemon itself. The maintainer's analysis on #2785 spells this
out: the gateway ticks the scheduler every 60s, and without it
'Gateway not configured' just sits there.

The Tasks panel already shows a banner explaining this, but doesn't
give the user anywhere to go. Two small docs-shaped changes:

1. Add a 'Scheduled jobs require a gateway daemon' section to
   docs/docker.md under 'What goes wrong' with the two-container
   compose command and a verify step. Cross-linked from the existing
   short paragraph higher up so both entry points land on the same
   fix.

2. Append a 'How to enable scheduled jobs in Docker' link to the
   cron panel banner (loadCronGatewayNotice) pointing at the new
   docs anchor when the gateway is unconfigured. The banner text
   itself is unchanged.

Verified locally by serving the WebUI without a gateway, opening
Tasks, and confirming the banner now shows the new link; clicked it
and confirmed it lands on the new docs section. With the gateway
running the banner stays hidden as before.

Refs #2785
2026-05-25 11:35:24 -07:00
Sanjay Santhanam 51f333fef0 fix(providers): clearer toast on 403 when removing a provider key
The Remove button under Settings -> Providers calls
POST /api/providers/delete, which runs through _check_csrf. When the
CSRF cookie/header pair has drifted (typically a tab opened before the
most recent login or cookie rotation), the server returns 403 with the
string 'Cross-origin request rejected'. That string reads like a
reverse-proxy deployment problem and gives the user no next step (#2572).

Surface a recovery-shaped toast on 403 from this endpoint:
'Session expired. Reload the page and try again.' The underlying
server response is unchanged so logs/diagnostics still see the original
string; only the user-facing toast is replaced for this code path.

Verified locally by patching _check_csrf to return False, clicking
Remove on a provider card, and confirming the toast now reads the new
message instead of the raw cross-origin string.

Refs #2572
2026-05-25 11:28:14 -07:00
Sanjay Santhanam d86e841f0b fix(transcript): make tool cards visually distinct without hover
The tool-card border-subtle was so faint that the cards visually melted
into the surrounding prose once the cursor left the conversation. Bumps
the resting border to --border-muted and adds a 2px left edge so a tool
output row reads as metadata at a glance, even on light skins where
border-subtle is barely visible. Hover still escalates to --border2.

Verified by loading a session with mixed tool calls and assistant prose
on the light theme and confirming the tool cards are now identifiable
without mousing over them.

Refs #2867
2026-05-25 11:22:35 -07:00
Sanjay Santhanam fb822239ea fix(#1937): close endless-scroll prefetch vs Start-jump race with generation-token + mutex
The originally-proposed fix (gate _ensureAllMessagesLoaded on the existing
_loadingOlder flag) does not actually close the race. By the time the
prefetch reaches its post-await body, it has already cleared the entry-
gate that reads _loadingOlder, so a same-flag check inside the resolved
callback would be a no-op for an in-flight request.

The actual fix is two-pronged:

1. New module-scoped _messagesGeneration counter, bumped every time
   S.messages is wholesale-replaced. _loadOlderMessages snapshots it
   BEFORE its await and re-checks after — if it changed, the prepend
   is aborted. This is the canonical async-invalidation pattern.

2. _ensureAllMessagesLoaded now claims the _loadingOlder mutex around
   its body so a new prefetch cannot start mid-replace and concurrent
   ensure-all calls (rapid double-click on Start) serialize cleanly.
   It bumps the generation token before mutating S.messages, yields
   until any in-flight prefetch finishes, and resets _oldestIdx so a
   subsequent prefetch cannot request stale older messages.

Also adds the same-session / _loadingSessionId guards that the original
ensure-all body was missing post-await — if the user switched sessions
mid-flight, the old code would happily overwrite the new session's
messages with the previous session's full history.

12 new regression tests in tests/test_issue1937_endless_scroll_jumpstart_race.py
lock in: generation token declaration, bump-helper presence, snapshot-
before-await ordering, post-await-abort behaviour, mutex acquisition and
finally-release, yield-then-claim ordering when a prefetch is in flight,
generation bump during the wait phase, _oldestIdx reset, and the new
session-switch guard.

Closes #1937.
2026-05-08 21:14:22 -07:00
Sanjay Santhanam efcfff3d7f fix(#1879): cross-container gateway liveness via state-file freshness
The dashboard banner 'Hermes agent is not responding' fires on every
multi-container deployment that doesn't set 'pid: "service:hermes-agent"'
in compose, because get_running_pid() relies on fcntl.flock and
os.kill(pid, 0) — both PID-namespace-scoped and invisible across container
boundaries.

Fix: when get_running_pid() returns None, fall back to a freshness check on
gateway_state.json. The gateway already writes that file on every tick with
gateway_state == 'running' and an aware ISO-8601 updated_at timestamp, so a
recent (<= 120s) timestamp is an equivalent live-process signal that needs
only a shared volume — no PID namespace, no compose workaround, no extra
HTTP probe URL.

Behavior preserved:
- In-namespace deployments still hit the PID-based path first; payload shape
  unchanged (no 'reason' key) so #716 contract holds.
- Cross-container alive path adds reason='cross_container_freshness' so
  support diagnostics can tell which signal succeeded.
- Stale updated_at, non-running gateway_state, malformed/naive/missing
  timestamps, and timestamps far in the future all still report 'down' — the
  fallback never produces a false positive.
- Same redaction rules: argv/command/executable/env/raw pid never leak.

Tests: 15 new cases in test_issue1879_cross_container_gateway_liveness.py
covering the cross-container alive path, every refusal case, clock-skew
tolerance, and backward compat with the #716 PID path. Existing #716
heartbeat tests (8) continue to pass.
2026-05-08 15:15:50 +00:00
Sanjay Santhanam a958c29373 fix(config): phantom Custom group when active provider is ai-gateway (#1881)
Two bugs in get_available_models() conspired to duplicate the active
provider's auto-detected models under a phantom 'Custom' group whenever
custom_providers was also declared in config.yaml:

1. custom:* PIDs not in _named_custom_groups (e.g. stale slugs left from
   prior configs) fell through to the auto_detected_models fallback, copying
   the active provider's whole catalog into a phantom Custom: <slug> group.
   Fix: continue unconditionally for ANY custom:* PID — the named-group
   branch is the only legitimate population path.

2. The bare 'custom' PID, with the active provider being concrete (e.g.
   ai-gateway), hit 'elif auto_detected_models: copy.deepcopy(...)' and
   built a duplicate Custom group of the active provider's models with
   mismatched provider prefixes. Fix: when pid == 'custom' and the active
   provider is non-custom, leave models_for_group empty.

The reporter also suggested a third fix gating resolve_model_provider() on
config_provider — that's intentionally NOT applied because it conflicts with
the long-standing model-specific-override semantics covered by
test_model_resolver.py::test_custom_provider_*_routes_to_named_custom_provider
(custom_providers entries explicitly override the active provider's routing
when the user opted-in). The reporter's symptom (duplicate UI group) lives
entirely in get_available_models()'s group construction and is fully fixed
by the two changes above.

Tests: 6 new regression tests (3 in #1881 file + reuse), 774 broader
tests still green (model/provider/custom/config domain).
2026-05-08 15:15:49 +00:00
Sanjay Santhanam 064d14c85b fix(config): custom provider + :free/:beta/:thinking suffix mis-resolution (#1776)
PR #1762 fixed the rsplit grammar collision for plain @openrouter:model:free
qualifiers, but skipped the fallback whenever the provider hint started with
'custom:' on the assumption that custom providers route directly. That left
'@custom:my-key:some-model:free' broken: rsplit yields
provider='custom:my-key:some-model', bare='free' → custom guard skips the
split-fallback → returns provider='custom:my-key:some-model', model='free'.

Detect the over-split structurally instead of using a known-suffix allowlist:
custom hints carry exactly one segment after 'custom:' (constructed at
api/config.py:1363 as 'custom:' + entry_name). So any rsplit result of
'custom:<a>:<b>' with bare model '<c>' has eaten one model segment — peel
it back with a second rsplit and prepend it to the bare model.

This is robust for :free / :beta / :thinking / :preview / any future
OpenRouter suffix without an allowlist to maintain.

Adds 5 regression tests covering the matrix (free/beta/thinking/preview/
slashed-model). All 7 existing #1744 tests still pass; #1228 tests
unaffected.

Co-authored-by: Cake <51058514+Sanjays2402@users.noreply.github.com>
2026-05-07 06:25:16 +00:00
Sanjays2402andPotato 9bb4fad0e8 fix(streaming): unpin scroll on small upward motion during streaming (#1731)
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>
2026-05-06 06:26:28 +00:00
Sanjay Santhanam 14fac05dc9 fix(streaming): use truthy-check for _pending_started_at fallback
Switch the per-turn duration fallback from `is not None` to a truthy check so
None, missing-attr, and an explicit 0 all uniformly fall back to time.time().

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

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

Fixes #1595

Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
2026-05-03 23:21:19 -07:00