# Two Clocks, Neither Lying

> Source: <https://dev.to/jeromefromhk/two-clocks-neither-lying-4k2l>
> Published: 2026-08-15 13:51:08+00:00

The night of August 14th, Claude Code was planning me a trip. An eight-day itinerary — not code, just travel planning — running in the VS Code extension against the remote box where my sessions live. Just past midnight my phone buzzed: the turn had finished, and Claude was waiting for me to approve its plan. That buzz was `claude-code-notify`

, a hook I wrote myself so that a long turn, or one waiting on my input, wouldn't pass unnoticed — before it existed, nothing notified me at all. I saw the message and switched to the window. The session tab said the turn was still executing.

Both indicators were right, and the hour between them was the bug. It just took a while to see how a session could be finished and not finished at the same time.

The extension wasn't frozen, and it wasn't empty. Every so often it nudged a little more text into view — and the text was old, stale by the time it reached the screen. It wasn't showing me nothing. It was showing me the past, slowly.

That distinction did most of the diagnostic work before I touched anything. A lost handshake drops things: events go missing, the session looks truncated, the missing pieces never show up. A queue does the opposite: everything arrives, in order, late. What I was watching wasn't a failure to deliver — it was a delivery schedule. The events weren't lost; they were stacked up somewhere between the CLI and the screen, and something at the front of the stack moved slowly.

The stalled session's process was still alive — asleep, not spinning. No CPU worth mentioning, zero network connections, zero child processes. A turn waiting on a model has a socket open; a turn running a command has children. This one had neither. It wasn't waiting on the internet and it wasn't waiting on a shell. It was waiting on something local, the way a program waits on a pipe.

The session's own transcript said the same thing, more precisely. Claude Code journals every session as a file of JSON lines, and the last line of that night's was a complete assistant message — usage accounted for, stop reason recorded — ending in a tool call: the request for me to approve the plan it had drafted. After that line, nothing. No result, no close-out. The CLI had finished producing its question and never heard the turn end; it still believed the session was mid-flight. Even the tool-call id carried a small confession — its format was the third-party provider's, not Anthropic's, proof of which road the request had taken.

I run Claude Code through claude-code-router on that box — ccr, a self-hosted router that lets me point the same workflow at the official Anthropic subscription or at third-party providers. That night it pointed at a Zhipu GLM coding plan. The router logs every request it forwards, so "did the model side actually finish?" had a direct answer: the turn's request completed in 1.7 seconds — HTTP 200, no retries, no credential throttling — and in the surrounding hour, 186 requests, all 200. Everything upstream of the CLI had finished long before I started asking questions.

That pinned the stall to one segment: after the CLI, before the screen.

Which left a short list of suspects, and a matrix that cleared it in one pass. Terminal plus ccr: fine. Extension plus the official API: fine. Extension plus ccr: stuck. The two working combinations have nothing in common except that they both avoid the failing one — so the variable isn't the router and isn't the extension. It's what the stream looks like when the extension consumes what the router forwards.

The router's log had captured response bodies that night, and each body told the story in two numbers:

| Logged request | Output tokens | SSE delta events |
|---|---|---|
| 749 | 6,400 | 6,314 |
| 745 | 3,822 | 3,803 |
| 746 | 2,877 | 2,720 |

Nearly one streaming event per token, roughly 135 bytes each — 855 KB of server-sent events for a single response — and with thinking mode on, about 99% of them were thinking deltas. The official Anthropic API doesn't stream like that: it merges many tokens into each delta event, which puts its event rate one to two orders of magnitude lower. Same conversation, same renderer — wildly different event counts. (Those bodies are gone now; I've since turned capture down to failures only, so this table is a record of one night, not a measurement you could rerun on my box today.)

Now the mechanism. For every streaming event, the extension does three kinds of work: parse the event's JSON, post a message across the Remote-SSH tunnel into the webview, and re-render the conversation — the whole conversation, which on a long session is not cheap. None of it is slow per event. It's slow per six thousand events, and the provider side was producing them faster than the extension could burn them down.

Between the CLI and the extension sits a plain stdout pipe, 64 KB by default. When the reader falls behind, the pipe fills, and then the writer blocks: the CLI's next write stalls until the extension drains some of the backlog. Everything the CLI tries to say next — including small control messages, like *I need you to approve this plan* — queues behind tens of thousands of tiny deltas waiting their turn at the renderer. An approval prompt that was ready at 12:28 a.m. reaches the screen an hour later, not because anything crashed, but because it joined the back of a very long, very slow line.

And that is why my phone was right. claude-code-notify hangs off the CLI's own hooks — the session lifecycle — not off the extension's rendering channel. The CLI finished its message and raised its approval request on schedule; the hook fired on time, straight to Telegram, without ever standing in the render queue. The two clocks weren't disagreeing about one quantity. One was reading the session. The other was reading a queue.

The notification reported the session: message finished, approval waiting — correct, at 12:28. The extension reported its render queue: thousands of events deep and moving slowly — also correct, about a different thing. What looked like a contradiction was two clocks measuring two different pipes, and the hour between them was just the drain time.

There's a real design question upstream of my setup, though. A renderer this slow shouldn't be able to stall the protocol channel: stream events can be batched for display without making control messages wait behind them. Per-token granularity exposed it, but the coupling is the extension's. I've filed it upstream — [anthropics/claude-code#86854](https://github.com/anthropics/claude-code/issues/86854) — with the event-granularity data.

The fix, when it came, wasn't in the extension at all — it was a small middleware that merges those deltas back into chunks before the stream ever reaches the pipe. Getting it actually loaded took five attempts, four of them failures. That's the next piece.

That fix is the subject of the next piece — [Coalescing the Stream](https://dev.to/jeromefromhk/TODO-coalescing-the-stream).
