Fix proposal: streaming reliability, SDK restructuring, and architectural recommendations (with source references)

Resolved 💬 2 comments Opened Apr 1, 2026 by kolkov Closed May 8, 2026

We reverse-engineered Claude Code across 12 versions before the source map leak, then validated everything against the v2.1.88 TypeScript source. This issue consolidates all findings into actionable fix proposals with exact file paths, line numbers, and code.

Full research: dev.to article | Root cause analysis #33949 | Watchdog dead code #39755

---

Level 1: Immediate fixes (~30 lines, 3 files)

Fix 1: Watchdog timing — protect the initial connection phase

File: src/services/api/claude.ts

The streaming idle watchdog (lines 1868-1929) initializes after the do-while loop (lines 1849-1856). The most vulnerable phase — waiting for the first API response — is completely unprotected. This is where 100% of our observed hangs occur.

Evidence: We patched the minified cli.js to move watchdog init before do-while. Result: watchdog fired for the first time ever in the initial connection phase. ESC aborts dropped 8.7× (3.5/hr → 0.4/hr). Data from 3,539 API requests across 6 days.

Fix: Move lines 1868-1929 before line 1849. Additionally, create a dedicated AbortController so the watchdog can actually abort the pending request:

// BEFORE do-while:
const watchdogController = new AbortController()
const combinedSignal = AbortSignal.any([signal, watchdogController.signal])

// In watchdog timeout callback (line 1911):
watchdogController.abort()  // ← actually aborts the request!
releaseStreamResources()

// Pass combinedSignal to withRetry (line 1843):
signal: combinedSignal,

Without this, releaseStreamResources() (line 1519) tries to abort stream and streamResponse — both undefined during do-while. The abort is literally a no-op.

Fix 2: Watchdog fallback — let it reach the recovery code

File: src/services/api/claude.ts, catch block (~line 2434)

When watchdog fires during for-await, the AbortError instanceof check throws before reaching the non-streaming fallback code. The fallback has telemetry (fallback_cause: "watchdog") proving someone wrote it for this case — but it's unreachable.

// Current (line ~2434):
if (streamingError instanceof APIUserAbortError) {
  if (signal.aborted) throw streamingError      // user ESC — correct
  else throw new APIConnectionTimeoutError()     // SDK timeout — BLOCKS FALLBACK
}

// Fix:
if (streamingError instanceof APIUserAbortError) {
  if (signal.aborted) throw streamingError
  else if (streamIdleAborted) { /* fall through to fallback below */ }
  else throw new APIConnectionTimeoutError()
}

Fix 3: Promise.race tool result loss

File: src/utils/generators.ts, function all() (lines 42-73)

The concurrent tool merger uses .then() without .catch():

// Line 50-55:
const promise = generator.next()
  .then(({ done, value }) => ({ done, value, generator, promise }))
  // ← NO .catch()! One rejected generator kills ALL pending tools.

The developer even left a TODO on line 64: // TODO: Clean this up

Fix: Add .catch() to convert rejections into completed generators:

const promise = generator.next()
  .then(({ done, value }) => ({ done, value, generator, promise }))
  .catch(err => ({ done: true, value: undefined, generator, promise, error: err }))

---

Level 2: SDK restructuring — move reliability to the open SDK

Repo: @anthropic-ai/sdk (MIT license, github.com/anthropics/anthropic-sdk-typescript)

File: src/core/streaming.ts

The SDK currently ignores SSE ping events (line 78):

if (sse.event === 'ping') { continue; }

Ping = proof-of-life from the server. It proves the connection is alive and the model is working. The watchdog should use pings to distinguish two fundamentally different situations:

  • Dead connection (no data, no pings) → abort quickly (network idle, 120s)
  • Model thinking (pings arriving, no content) → don't abort! Notify user instead

Proposed: Three-level adaptive timeout in the SDK

interface StreamIdleOptions {
  connectionTimeout?: number      // 30s — server didn't respond at all
  networkIdleTimeout?: number     // 120s — no data INCLUDING pings = dead connection
  contentIdleTimeout?: number     // null — pings alive but no content = model thinking
  onPing?: () => void             // UI: "model thinking..."
  onNetworkIdleWarning?: (ms: number) => void
}

Informational pings: Currently pings carry no payload (data: {}). The server could enrich them: data: {"status":"thinking","elapsed_ms":95000,"queue_position":3,"model_load":0.87} — giving clients everything needed for intelligent UX without a separate diagnostic channel.

Informational pings: Currently SSE pings carry no payload — just event: ping\ndata: {}\n\n. The server could enrich them with diagnostic context: data: {"status":"thinking","elapsed_ms":95000,"queue_position":3,"model_load":0.87}. This gives the client everything it needs for intelligent UX — show "thinking for 1m 35s", warn "server load 87%", display "3rd in queue" — all through the existing SSE connection, no separate diagnostic channel needed. The client-side cost is zero: pings already arrive, just parse the payload.

This belongs in the open SDK, not in closed cli.js. Currently Claude Code disables SDK retry (maxRetries: 0, claude.ts line 1781 — with comment: "Disabled auto-retry in favor of manual implementation") and reimplements everything in 822 lines of custom retry logic (withRetry.ts) plus 3,419 lines of streaming code (claude.ts). All of this could be ~50 lines of SDK configuration if the SDK handled reliability properly.

Every Anthropic API client would benefit — not just Claude Code.

---

Level 3: The right architecture — or the right language

Input latency problem (architecturally unsolvable in current stack)

File: src/screens/REPL.tsx5,005 lines, 875 KB, single component

During streaming (10-50 SSE events/sec), each event → setState → React reconciliation → stdout.writeblocks the single Node.js thread. User keystrokes queue behind renders.

The source confirms they know: line 1318 uses useDeferredValue(messages), line 1321 logs deferred count, print.ts:1832 has setTimeout(0) to "yield to event loop for pending stdin." All bandaids.

470 useState hooks, 372 useEffect hooks — for a terminal application. React was designed for browsers where rendering, input, and network run on separate threads. In a terminal, it's all one thread.

Why Go

Every bug we found exists because of the technology choice:

  • setTimeout not firing during for await → Go: context.WithTimeout (guaranteed)
  • 5 logical levels of AbortController → Go: one context.Context (propagates everywhere)
  • Promise.race without .catch() → Go: channels are type-safe, can't silently drop
  • React single-thread blocking input → Go: goroutines (render + input = separate threads)
  • 13 MB minified JS + runtime → Go: 15 MB static binary, zero dependencies
  • 500 MB per process → Go: 50-100 MB, GC returns memory to OS proactively

Every serious CLI tool is Go: Docker, Kubernetes, Terraform, GitHub CLI.

The Go ecosystem has production-ready libraries for every component: Phoenix TUI (terminal framework), stream (SSE/WebSocket), signals (reactive state), coregex (3-3000× faster regex), uniwidth (Unicode width for TUI), gosh (cross-platform shell), fursy (HTTP router + OpenAPI), pubsub (messaging + DLQ).

---

Data

From a real 6-day session (gogpu project, 3,539 API requests):

| Metric | Value |
|--------|-------|
| 529 Server Overloaded | 328 (9.3%) |
| ESC aborts (manual) | 157 (4.4%) |
| Watchdog timeouts | 45 (1.3%) |
| Total failures | 576 (16.3%) |

Every 6th request fails on a paid Max plan.

After our watchdog timing patch: 0 ESC aborts needed — watchdog fires automatically.

v2.1.89 was released — source map removed, zero fixes for any streaming issues.

---

Environment

  • Claude Code: v2.1.88 source (leaked), v2.1.89 (tested)
  • OS: Windows 10, npm/Node.js
  • Model: Opus 4.6, 1M context
  • Analysis: 12 versions reverse-engineered, 1,571 sessions, 148,444 tool calls

A note on approach

These aren't just prompts to copy-paste. The Level 1 fixes will stop the bleeding, but the real problem is architectural. Before writing any code, someone needs to sit down and think about:

  1. Why does a CLI tool need 5 logical levels of AbortController? Because abort was designed top-down (user ESC) and watchdog was bolted on later bottom-up. A redesign would unify these.
  1. Why is reliability logic in closed cli.js instead of the open SDK? Every Anthropic API client faces the same streaming hangs. The SDK (@anthropic-ai/sdk, MIT) handles none of it. Claude Code disables SDK retry and reimplements everything — 4,241 lines of custom reliability code that should be 50 lines of SDK configuration.
  1. Why does a terminal app use React with 470 useState hooks? The input latency during streaming is architecturally unfixable in a single-threaded runtime with virtual DOM reconciliation. This isn't a bug — it's a design choice that traded developer convenience for user experience. The reactive signals pattern (Angular Signals, SolidJS, Go signals) solves this: fine-grained reactivity updates only what changed — no virtual DOM diff of the entire 5,005-line component tree. A streaming text delta updates one signal → one terminal write. Input handling runs independently, never blocked by render. Same reactive developer experience, zero reconciliation overhead.
  1. Why are there 727 lines tracking 14 cache-break vectors (promptCacheBreakDetection.ts)? Because the append-everything context architecture creates 200-500K token requests where cache misses are catastrophic. With intelligent context selection (30K focused tokens per request), caching becomes a bonus instead of a critical dependency.
  1. Why are SSE pings wasted? The server already sends pings as heartbeats — but they carry zero information (data: {}). These pings are the only real-time channel between server and client during model thinking. Enriching them with {"status":"thinking","elapsed_ms":95000,"queue_position":3,"model_load":0.87} solves multiple problems at once: the watchdog knows the connection is alive (no false positive abort), the user sees what's happening (no "is it frozen or thinking?"), and the client can make adaptive decisions (back off on high server load, warn user about queue position). This isn't a feature request — it's an architectural insight: the communication channel already exists, it's just not being used. Design the ping protocol as a first-class contract between server and client, not an afterthought that gets continued away.

Don't just patch the symptoms. Rethink why they exist.

The source map leak happened once. Next time the code is exposed — through a leak, a decompiler, a security audit, or an intentional open-source release — it should be code that the team is proud of. Enterprise-grade architecture. Proper separation of concerns. Tests. Clean modules instead of a 5,005-line single component. A streaming pipeline that a new engineer can read and understand in an afternoon, not one that took us two weeks to reverse-engineer.

The models are world-class. The CLI should match that standard. Think deeply, design properly, build something worthy of a $2.5B ARR product.

CC: @bcherny @ant-kurt @fvolcic @ashwin-ant @bogini @OctavianGuzu @hackyon-anthropic @chrislloyd @ThariqS @catherinewu @whyuan-cc @dhollman @rboyce-ant @dicksontsai @wolffiex @ddworken @km-anthropic

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗