From a906172807adb1a5822212b05ebe1f0c1092d533 Mon Sep 17 00:00:00 2001 From: jasonjaclyn2017 Date: Wed, 4 Mar 2026 20:59:43 -0600 Subject: [PATCH] fix: handle SDK keep_alive messages to prevent idle timeout during long operations (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream idle timer (330s) fires during long-running Claude Code tool executions because the SDK's keep_alive heartbeat messages are silently dropped. This causes a false "Stream idle timeout" error even though Claude is still actively working. Root cause: claude-client.ts only handles 6 SDK message types in its switch statement and has no default case, so keep_alive messages from the SDK are never forwarded to the frontend. The idle timer in stream-session-manager.ts never gets reset by markActive(). Fix: thread keep_alive through the existing 3-layer event pipeline: - claude-client.ts: forward keep_alive as SSE event (via default case) - useSSEStream.ts: add onKeepAlive callback to SSECallbacks interface - stream-session-manager.ts: call markActive() on keep_alive to reset the idle timer No visible UI change — keep_alive only resets the timer silently. Co-authored-by: Claude Opus 4.6 --- src/hooks/useSSEStream.ts | 7 +++++++ src/lib/claude-client.ts | 7 +++++++ src/lib/stream-session-manager.ts | 3 +++ src/types/index.ts | 1 + 4 files changed, 18 insertions(+) diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index d955dd2..d313073 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -24,6 +24,7 @@ export interface SSECallbacks { onToolTimeout: (toolName: string, elapsedSeconds: number) => void; onModeChanged: (mode: string) => void; onTaskUpdate: (sessionId: string) => void; + onKeepAlive: () => void; onError: (accumulated: string) => void; } @@ -146,6 +147,11 @@ function handleSSEEvent( return accumulated; } + case 'keep_alive': { + callbacks.onKeepAlive(); + return accumulated; + } + case 'error': { const next = accumulated + '\n\n**Error:** ' + event.data; callbacks.onError(next); @@ -232,6 +238,7 @@ export function useSSEStream() { onToolTimeout: (n, s) => callbacksRef.current?.onToolTimeout(n, s), onModeChanged: (m) => callbacksRef.current?.onModeChanged(m), onTaskUpdate: (s) => callbacksRef.current?.onTaskUpdate(s), + onKeepAlive: () => callbacksRef.current?.onKeepAlive(), onError: (a) => callbacksRef.current?.onError(a), }; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 852860d..61a813f 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -873,6 +873,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { + markActive(); + }, onError: (acc) => { markActive(); stream.accumulatedText = acc; diff --git a/src/types/index.ts b/src/types/index.ts index 260d91e..094b60e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -356,6 +356,7 @@ export type SSEEventType = | 'permission_request' // permission approval needed | 'mode_changed' // SDK permission mode changed (e.g. plan → code) | 'task_update' // SDK TodoWrite task sync + | 'keep_alive' // SDK keep-alive heartbeat (resets idle timer) | 'done'; // stream complete export interface SSEEvent {