feat(server): SSE heartbeat on streaming path (#47) - #49
Merged
Conversation
Draft spec for an opt-in idle-watchdog SSE heartbeat covering both pre-first-byte and mid-stream silent windows. Default disabled, controlled by CLAUDE_HEARTBEAT_INTERVAL. Targets ~40 LOC. Decisions captured: D1 whole-stream reset-on-byte; D2 SSE comment frame; D3 default disabled; D4 relocate ensureHeaders() to post-spawn; D5 X-Accel-Buffering: no on both SSE header sites; D6 single log line per affected request. Scope-locked: does not touch CLAUDE_TIMEOUT semantics, the separate server.mjs:480-489 dangling-client bug, issues #41/#42, or the non-streaming path. Includes privacy preflight and cloud-testing plan per maintainer feedback. Refs: #47 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 phases: pre-work (file 480-bug), implementation (5 tasks on feat/47-sse-heartbeat), opus fresh-context review, cloud verification on Mac rig, privacy preflight, PR+release. Each implementation task carries concrete code, syntax check, and a scoped commit message. LOC budget enforced in Task 1.6 gate (~45 server.mjs lines max). Reviewer checklist scopes scope-lock, ALIGNMENT, privacy, and heartbeat-cannot-abort discipline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per design doc (refs #47). Helper is a per-request idle watchdog that emits `: keepalive\n\n` SSE comment frames; returns a {reset, stop} handle. No wiring yet — helper is unused, safe to commit in isolation. cli.js citation: N/A — SSE response shaping is an OCP-owned translation layer, not a cli.js operation. See AGENTS.md and ALIGNMENT.md Rule 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the ensureHeaders() call from "on first stdout byte" to "immediately after successful spawn." This is a prerequisite for the heartbeat covering the pre-first-byte silent window (the 'processing large contexts' failure mode in #47). Behavioral consequence: the narrow "spawn succeeded but subprocess died before any byte" branch at server.mjs:610-611 becomes effectively dead in the common case. The post-headers SSE-stop path (612-619) handles it instead. The branch remains defensively for the client-closed-before- ensureHeaders race. Isolated commit per design doc §D4 so reviewer can focus on this one behavior change. cli.js citation: N/A — SSE header emission is OCP response-shaping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sendSSE() accepts optional hb handle and calls hb.reset() before write - callClaudeStreaming starts heartbeat after ensureHeaders() and passes hb to the three streaming sendSSE call sites - All three exit paths (proc close, proc error, res close) call hb.stop() to guarantee timer cleanup; no-op handle when disabled means zero runtime cost when CLAUDE_HEARTBEAT_INTERVAL=0 Heartbeat never aborts — only writes comment frames and re-arms. Aligns with v3.3 timeout discipline (single CLAUDE_TIMEOUT, no secondary client-killing timers). cli.js citation: N/A — SSE response shaping is OCP translation layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…47) nginx (and many LBs / Cloudflare) default to proxy_buffering=on, which would buffer heartbeat comment frames indefinitely and defeat the feature silently. This header hints no-buffering; other stacks ignore it. Applied at both SSE header sites (real streaming + cache-hit). cli.js citation: N/A — response header shaping is OCP translation layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles the release-kit companion files per Iron Rule 5.2 / 11 example: version bump across package.json + ocp-plugin + openclaw.plugin.json, CHANGELOG v3.12.0 section, README env var row + "Streaming heartbeat" explainer. Tag push to v3.12.0 triggers .github/workflows/release.yml to create the GitHub Release automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 smoke test revealed every content chunk was being dropped
after the D4 eager ensureHeaders() call: the stdout.on('data') guard
`if (!ensureHeaders()) return;` early-returned on every chunk because
ensureHeaders returned false for the already-sent case (conflated with
the dead-connection case).
Split the two conditions explicitly: return false only when res is
ended/destroyed; return true when headers are (already or now) sent.
This also fixes a latent multi-chunk bug on main that was masked
because claude CLI typically outputs in one stdout chunk.
Verified: node -c server.mjs; subsequent re-run of Phase 3 smoke test
now sees streaming content chunks + heartbeat frames.
cli.js citation: N/A — SSE response shaping is OCP translation layer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced May 3, 2026
dtzp555-max
added a commit
that referenced
this pull request
May 4, 2026
…rtbeat-field-check.sh (#55) Two related changes — both repo-hygiene, no behavior impact. ### `.gitignore` (new) Ignores runtime artifacts that should never be tracked: - `logs/` and `*.log` — proxy.log, last_send.log, heartbeat-field-check log - `node_modules/` — dependency cache - `.env`, `.env.*` — local secrets/config - `.DS_Store`, `*.swp`, `*~` — editor/OS scratch `logs/` was previously untracked-but-present in working trees; the new ignore makes that intent explicit and prevents accidental commits. ### `scripts/heartbeat-field-check.sh` (commit existing untracked file) This script was authored as a one-shot field-evidence gatherer for the v3.12.0 SSE heartbeat work (PR #49 / issue #47). It fired successfully on 2026-05-02 09:00 Australia/Brisbane via launchd (`~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist`) and posted a summary comment to issue #47. Useful tooling pattern (one-shot field check + launchd schedule + dry-run flag), worth keeping under version control rather than letting it die in an untracked working tree. Verification: `git status` clean after both adds. Co-authored-by: dtzp555 <dtzp555@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Opt-in SSE heartbeat on the streaming path (
CLAUDE_HEARTBEAT_INTERVALenv, ms;0=disabled, default). Emits: keepalive\n\ncomment frames on idle windows (reset on every real chunk), covering both the pre-first-byte pause and mid-stream tool-use pauses. Also addsX-Accel-Buffering: noon SSE responses so heartbeats survive nginx/Cloudflare default buffering. Addresses #47 without re-introducing any v3.3-era client-killing timer.Claude Code Alignment Evidence (REQUIRED)
Corresponding
cli.jsreference. N/A — see next box.If
cli.jsdoes not perform this operation, I have stated this explicitly below and justified the scope underALIGNMENT.mdRule 2.cli.jsdoes not perform SSE response shaping.claude -pspeaks newline-delimited JSON to stdout, not SSE. SSE is an OCP-owned translation layer that converts that stdout stream into OpenAI-compatibledata:chunks. This PR adds an application-layer liveness signal (comment frames) on that existing translation layer. PerAGENTS.md("OCP forwards, observes, and multiplexes traffic thatcli.jsalready emits"), response shaping is in-scope for OCP; perALIGNMENT.mdRule 2, no new endpoint is introduced. See design doc atdocs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md§"ALIGNMENT.md disposition".Commit message citations. Every commit that touches
server.mjsincludes the linecli.js citation: N/A — SSE response shaping is OCP translation layer.The two doc-only commits (30753e3,6e7ec19) and the release-kit commit (eedf176) do not touchserver.mjs.Type of change
cli.jssurface change)Reviewer checklist
cli.jsdoes not perform SSE response shaping; the Rule-2 justification is recorded in the spec and in everyserver.mjscommit message. Independent reviewer openscli.jsseparately if they want to re-verify the absence.Related
ALIGNMENT.mdRule(s) invoked: Rule 2 (no invention — justified: SSE translation layer, not a new endpoint)server.mjs:480-489dangling-client timeout behavior)docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.mddocs/superpowers/plans/2026-04-25-47-sse-heartbeat-plan.md3843ec8) — the heartbeat in this PR is an observability hint only; itsonFirecallback only writes and re-arms, never callsres.end()/proc.kill/ throws. This discipline is what the v3.3 lesson requires.User-visible change self-check
CLAUDE_HEARTBEAT_INTERVAL. CHANGELOG v3.12.0 entry documents the feature and theX-Accel-Buffering: noheader.Privacy self-check
noreply@anthropic.com(LLM co-author trailer).Grep evidence (run locally before push, plus blacklist re-run):
Independent review (Iron Rule 10)
Two fresh-context
opusreviewer runs were completed on this branch prior to PR open:Initial review (pre-Phase-3). Verdict
APPROVEagainst the 12-item checklist covering D1–D6, scope lock, ALIGNMENT, privacy, LOC budget, and heartbeat-cannot-abort invariant. Recommended KEEP_BUMP on theocp-plugin/*version files (aligning them to the mainpackage.jsonSPOT perCLAUDE.mdrelease-kit overlay; sibling plugin files had drifted to3.3.1while main was3.11.x).Delta review of
db11105(post-Phase-3 fix-up). VerdictAPPROVE. Phase 3 runtime verification surfaced that the D4 eagerensureHeaders()combined with the pre-existingif (!ensureHeaders()) return;guard insidestdout.on('data')silently dropped every content chunk (theheadersSentand dead-socket branches were conflated into a singlereturn false). Fix splits the two conditions. Reviewer also noted this fix closes a latent multi-chunk bug that existed onmainbut was masked because claude CLI typically emits its JSON body in a single stdout burst.Verification (Phase 3 — cloud-backed manual smoke + negative test)
Run against the Mac rig with v3.12.0 from this branch. All identifiers sanitized;
$OCP_KEYsubstituted for the real bearer;sessionshown as captured.Positive test (
CLAUDE_HEARTBEAT_INTERVAL=2000, 2000ms interval for fast observation):Negative test (
CLAUDE_HEARTBEAT_INTERVALunset, i.e. default0):Release kit
package.json3.11.1 → 3.12.0ocp-plugin/package.json3.3.1 → 3.12.0 (re-aligned to SPOT; reviewer-approved)ocp-plugin/openclaw.plugin.json3.3.1 → 3.12.0## v3.12.0section with Features / Behavior changes / Config additionsTag push to
v3.12.0triggers.github/workflows/release.ymlautomatically (perCLAUDE.mdrelease-kit overlay,auto_create_on_tag_push: true).🤖 Generated with Claude Code