Skip to content

Let the server push notifications to clients, starting with a heartbeat - #303

Merged
C-Hipple merged 1 commit into
mainfrom
claude/server-client-heartbeat-gqzvbu
Oct 25, 2026
Merged

C-Hipple merged 1 commit into
mainfrom
claude/server-client-heartbeat-gqzvbu

Conversation

@C-Hipple

Copy link
Copy Markdown
Owner

Summary

Until now, the server only ever sent replies to client requests. This PR gives it a way to send messages first, without the client polling: a client calls RPCHandler.Subscribe, and the server writes JSON-RPC 1.0 notifications (id: null, named by method) to the same stdout as its replies. The first notification is Server.Heartbeat, sent every interval. The web client relays these to the browser and shows whether the server is still alive.

Clients must opt in. A client that never subscribes never gets a notification, so the TUI, which reads exactly one line per call, keeps working. Emacs and the Chrome extension also don't subscribe and are unchanged.

Changes

  • server/notify.go: Notifier sends heartbeats (seq, time, started_at, interval_seconds) every interval: 10 s by default, clamped to 1 s – 5 min. Notifier.Notify(method, payload) is the hook for future events. Subscribe starts the heartbeats or changes their interval. Unsubscribe stops them, and nothing is sent after its reply. If a write fails, the client is unsubscribed instead of failing on every tick.
  • server/server.go: replies and notifications go through one locked writer (syncWriter), so two lines can't get mixed together.
  • bun_client/server.ts + server_events.ts: the bridge subscribes at startup (CRS_HEARTBEAT_SECONDS overrides the interval) and sends any message with a method to ServerEventHub instead of the reply map. The hub relays each one as a Server-Sent Event on GET /api/events. That connection has Bun's idle timeout turned off. A newly opened stream gets the latest heartbeat right away if the next one isn't due yet.
  • Frontend: server_events.ts keeps one EventSource that all listeners on the page share (serverEvents.on(method, cb)). useServerHeartbeat and ServerStatus show a small green dot in the top bar while heartbeats arrive. After three missed heartbeats, or if the stream drops, it shows Server not responding until the next one arrives. Nothing shows before the first heartbeat, so an older crs that doesn't send them isn't reported as down.
  • e2e harness: the fake crs answers Subscribe and sends a heartbeat every second. backend.setHeartbeat(false) stops them. The new heartbeat.e2e.ts covers live → lost → live, plus the raw SSE relay.
  • Docs: a "Server Notifications" section plus Subscribe/Unsubscribe in docs/protocol.md; building_clients.md, web_client_features.md, the e2e README and CLAUDE.md.

Possible follow-ups: the plugin and AI-output polling hooks (usePluginOutputs, useAIBackgroundRuns, useAIReport) could switch to pushed events through Notify / serverEvents.on. Emacs could also subscribe.

Test Plan

  • go build ./... passes
  • go test ./... passes. New server/notify_test.go runs under -race and includes a test of the real JSON-RPC codec, with 200 large replies interleaved with 1 ms heartbeats through a writer that splits writes into chunks. With the lock bypassed, that test fails.
  • bun run test (449 pass), bun run type-check, bun run type-check:e2e, bun run lint, bun run format:check
  • bun run test:e2e: 83 passed, 2 skipped (the opt-in real-LSP specs). Run against the preinstalled Chromium because the sandbox's build doesn't match the pinned Playwright.
  • Manually checked the real crs --server over stdio: the Subscribe reply, Server.Heartbeat each second, the Unsubscribe reply, then no more output.

Generated by Claude Code

Until now every message was a reply to a client request. A client can now
call RPCHandler.Subscribe and the server writes JSON-RPC 1.0 notifications
(id null, named by method) to the same stdout as its replies: a
Server.Heartbeat every interval (default 10 s, 1 s to 5 min), and anything
later sent through Notifier.Notify. Replies and notifications share one
locked writer, so a line is never spliced into another. Unsubscribe stops
them. A client that never subscribes never sees one, so the TUI, which reads
one line per call, is unaffected.

The web bridge subscribes at startup (CRS_HEARTBEAT_SECONDS overrides the
interval), keeps notifications out of its reply map, and relays them to
the browser as Server-Sent Events on GET /api/events. The page shares one
EventSource among its listeners; the top bar shows a dot while heartbeats
arrive and "Server not responding" after three missed ones or a dropped
stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3QMq6pKobmhf6euQvZf7T
@C-Hipple
C-Hipple merged commit 962919e into main Oct 25, 2026
8 checks passed
@C-Hipple
C-Hipple deleted the claude/server-client-heartbeat-gqzvbu branch October 25, 2026 21:46
C-Hipple added a commit that referenced this pull request Oct 26, 2026
* Refresh the web client live when a webhook delivery changes a PR

With [Webhooks] configured the server already patches its caches from each
delivery, but an open page only found out on a manual sync or a reload. Now
the server pushes a Server.PRUpdated notification (over the channel #303 added)
for every PR a delivery changed, and the web client re-reads the PR in place
and says what happened.

- workflows: each apply function reports what it rewrote for each PR
  (cache fields, "head" after a refetch, "pr" for a pull_request payload)
  and a few-word summary ("alice approved", "bob commented on x.go",
  "alice pushed new commits"). CI is announced only as it settles (passing,
  or failing where it wasn't); a check starting refreshes quietly.
  applyWebhookEvent hands each changed PR to a hook registered with
  SetPRChangedHook, and the membership re-check sends a "sections" change
  when it rewrote rows. Deliveries that changed nothing send nothing.
- server: NotifyPRChanged sends Server.PRUpdated (owner, repo, number, event,
  changes, head_sha, actor, own, summary, time) to a subscribed client;
  main.go wires it in server mode. "own" marks the configured user's changes.
- web client: onPRUpdates/usePRUpdates batch a burst (400 ms from the first
  update). The review re-calls GetPR without a loading state and takes in
  comments, reviews, metadata and a changed diff, keeping open threads,
  expanded hunks, drafts and the feedback being typed; a new diff waits
  while an inline comment is being written. The list re-reads its rows in
  place and highlights the changed ones. A toast says what happened; the
  user's own changes and quiet ones refresh silently.
- e2e: E2E.UpdatePR / backend.updatePR play a delivery; live_updates.e2e.ts
  covers the review, the held diff, the user's own change, sections-only
  updates and the list.
- docs: protocol.md (Server.PRUpdated), webhooks.md, web_client_features.md
  §3.18, building_clients.md, CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU

* Follow #305's change events in the live refresh

- Read again, quietly, when the event stream comes back after dropping
  (onReconnect, used by usePRUpdates), since a Server.PRUpdated sent while it
  was down is lost, as #305 does for plugin and AI changes. Unlike
  useServerChanges it skips the first connect, which only repeats the read
  the page made on load.
- Stop reloading the plugin output after a push: the plugins the push
  re-runs now announce themselves, and the reload put the panel through a
  loading state.
- Use #305's isChangeFor in place of the identical isUpdateFor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants