Skip to content

fix(blocks): four ways the transcript block fallback found the wrong block - #752

Merged
sirmalloc merged 8 commits into
sirmalloc:mainfrom
eric-engberg:fix/block-scan
Oct 29, 2026
Merged

sirmalloc merged 8 commits into
sirmalloc:mainfrom
eric-engberg:fix/block-scan

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

When Claude Code's payload has no rate_limits reset time (API-key, Bedrock and Vertex logins, accounts without a five-hour window, or a failed usage fetch), Block Timer, Block Reset Timer and Block % find the current 5-hour block from the transcripts. This fixes four ways that fallback got the block wrong:

  • Rows Claude Code writes for rejected requests no longer count as block activity.
  • A gap in a resumed session is no longer taken as the block boundary while a transcript the scan skipped could fill it.
  • Transcripts are found when the config directory's name has glob characters, e.g. ~/claude (work)/.claude.
  • Block cache entries written while the system clock ran ahead are no longer trusted after it is set back.

Why (evidence)

  1. Rejected requests. For a rejected request (a usage limit, an expired login, an outage), Claude Code writes {"type":"assistant","isApiErrorMessage":true,"message":{"model":"<synthetic>","stop_reason":"stop_sequence","usage":{"input_tokens":0,"output_tokens":0,...}}}. The scan only checked that input_tokens/output_tokens are numbers.
    • With only rejected requests in the last 5 hours, it showed a running block (e.g. "Block: 3hr 49m | Reset: 1hr 10m") that doesn't exist.
    • With rejections before real work, the block started at the first rejection's hour, so the reset was reported hours early.
    • Either wrong start was cached for 5 hours. The token/context metrics and Cache Timer already skip these rows.
  2. Skipped transcript. The scan reads in widening passes (10h, 20h, 48h). Each pass reads only the transcripts last written after its cutoff, but keeps all of their rows, so a session left open since yesterday brings in rows from long before the cutoff.
    • Example: session A has one row 30h ago and then a row every 30 min from 9h ago; another project's session B has rows 12h–10.5h ago and was last written 10.5h ago.
    • main reads only A, takes A's 21h gap as the boundary, never runs the wider passes, and anchors the block 2 hours early: "Block: 4hr 54m | Reset: 5m".
    • With B's mtime moved to 9.5h ago, the same code gives the correct "2hr 57m | 2hr 2m". Only the mtime differs.
  3. Glob characters. The pattern was <config dir>/projects/**/*.jsonl with the absolute directory written in. Under claude (work), cfg[1], brace{a}, star*x or q?mark it matched nothing, so Block Timer stayed at "0hr 0m" and Block Reset Timer at [Loading] for as long as that directory was in use.
  4. Clock set back. A "no block" entry (written one minute ahead) was trusted whenever now < emptyUntil. After a 24h-fast clock was corrected, it hid the block for a day, even with fresh activity. A cached start in the future counted as valid (now <= start + 5h), freezing elapsed time at 0 and Reset at 5hr. The usage lock in usage-fetch.ts already bounds its deadline against a clock set back.

How

  • getAllTimestampsFromFile skips rows with isApiErrorMessage === true, the same check the token/context metrics and Cache Timer use. Every main-chain <synthetic> row in a sample of real transcripts carried the flag and all-zero usage (session limit, spend limit, model limit, expired login, not logged in, sleep, unreachable API). This makes Block Timer differ from ccusage blocks, which counts these zero-token rows as entries; the scan's own comment says to treat only entries with real token usage as activity.
  • In the gap search, a gap of 5h or more is a boundary only if the part after the pass's cutoff spans 5h by itself. A transcript the pass skipped was last written before the cutoff, so it can only add rows before it. Otherwise the next pass reads it first, and the last pass takes any gap, as before.
    • Cost: in that layout, one more pass on a cache miss, which happens about once per block. It reads the transcripts written in the last 20h instead of 10h. On a synthetic 291 MB set that is 82 → 183 ms.
    • Layouts where the first pass finds a real gap, or no gap, are unchanged.
  • The glob pattern is the relative projects/**/*.jsonl, with the existing cwd: rootDir. tinyglobby escapes and normalizes the cwd itself, so Windows paths keep working.
  • Left as is: a backslash in the config directory's name on macOS or Linux. tinyglobby turns every backslash in the cwd into /, so a directory named back\slash still finds no transcripts, as on main.
  • getCachedBlockMetrics trusts emptyUntil only while it ends within one interval, and a cached start only once it has begun. Otherwise it rescans. (If a transcript itself holds rows stamped by the fast clock, the fresh scan still waits for them to age out; that's outside the cache.)
  • Unchanged: the lookback windows, how blocks are chained, the cache format, and the sidechain rule.

Demo

Piped renders of Block Timer and Block Reset Timer over fake transcripts in a scratch HOME. The payload has no rate_limits, there's no login and nothing is fetched. Times are relative to when each take was recorded.

Rejected requests. Line 1: only rejected requests, so there is no block. Line 2: rejections, then real requests from just after midnight, so the block is 00:00–05:00.

Powerline

Before:

Rejected requests before the fix, Powerline

After:

Rejected requests after the fix, Powerline

Plain

Before:

Rejected requests before the fix, Plain

After:

Rejected requests after the fix, Plain

A transcript the first pass skips. A resumed session plus another project's session last written 10.5h ago, whose rows chain into the current block.

Powerline

Before:

Skipped transcript before the fix, Powerline

After:

Skipped transcript after the fix, Powerline

Plain

Before:

Skipped transcript before the fix, Plain

After:

Skipped transcript after the fix, Plain

Config directory with parentheses. The same transcript under ~/claude-work/.claude and ~/claude (work)/.claude.

Powerline

Before:

Config dir with parentheses before the fix, Powerline

After:

Config dir with parentheses after the fix, Powerline

Plain

Before:

Config dir with parentheses before the fix, Plain

After:

Config dir with parentheses after the fix, Plain

Testing

  • 12 new tests: 5 config-dir names, 2 for rejected requests, 2 for the lookback gap, 2 for the cache bounds and 1 for the other rows the scan skips. 10 fail on main. The other two check that a gap no transcript fills is still a boundary, and that unparseable lines, rows without both token counts, sidechain rows and rows without a valid timestamp still don't count as activity; both pass on both.
  • bun test: 3180 pass, 0 fail (main: 3168), Bun 1.4.2. bun run lint is clean.
  • Node v26.10.0 (vitest) on the two changed test files: 36/36 (main 24/24).
  • Built dist under Bun 1.4.2 and Node 26.10.0: the baseline line matches main; a Block Timer + Block Reset Timer line over each of the three scenarios agrees across runtimes and differs from main as intended; the TUI opens and exits cleanly.

eric-engberg and others added 8 commits October 16, 2026 00:49
…cters

The transcript fallback for Block Timer, Block Reset Timer and Block %
globbed `<config dir>/projects/**/*.jsonl` with the absolute config
directory written into the pattern. Characters a directory name can
contain, such as ( ) [ ] { } * ?, are glob syntax inside a pattern, so
with CLAUDE_CONFIG_DIR at a path like "~/claude (work)/.claude" the
pattern never matched the literal path. Every scan found no
transcripts, so Block Timer stayed at "0hr 0m" and Block Reset Timer at
[Loading] for as long as that directory was in use.

The glob already runs with the config directory as its cwd, so the
pattern is now the relative `projects/**/*.jsonl`. The directory is
then only ever a path, never part of the pattern.
When a request is rejected (a usage limit, an expired login, an API
outage), Claude Code writes an assistant row with isApiErrorMessage set,
model "<synthetic>" and every usage field 0. The block scan only checked
that input_tokens and output_tokens were numbers, so these rows passed
as activity even though nothing was served:

- With only rejected requests in the last 5 hours, Block Timer and Block
  Reset Timer showed a running block that doesn't exist, e.g.
  "Block: 3hr 1m" / "Reset: 1hr 58m" instead of "0hr 0m" / [Loading].
- When rejected requests came before real work, the block started at
  the hour of the first rejection, so the reset was reported hours
  early.

Either wrong start was then cached for 5 hours.

The scan now skips rows flagged isApiErrorMessage, as the token and
context metrics and Cache Timer already do.
…may fill

To find the current 5-hour block, the transcript fallback walks back from
the newest activity to the first gap of 5 hours or more, then chains
blocks forward from there. It reads in widening passes (10h, 20h, 48h),
and each pass reads only the transcripts last written after its cutoff.
But it keeps every row in the transcripts it reads, so a session left
open since yesterday brings in rows from long before the cutoff. A gap
between those rows and today's was taken as the session boundary even
when another transcript, skipped only because it was last written
before the cutoff, has rows inside it. The wider passes never ran, the
chain started at the wrong row, and the block start came out hours
early: Block Timer read 4hr 57m instead of 2hr 57m, the reset was
predicted 2 hours early, and the wrong start was cached until then.

A skipped transcript can only hold rows from before the cutoff. So a gap
is now a boundary only if the part of it after the cutoff spans a
session by itself; otherwise the next, wider pass reads the skipped
transcripts first. The last pass still takes any gap, as before.
The block cache keeps either the current block's start or, after a scan
that found no block, an instant until which scans are skipped (one
minute ahead). Neither had an upper bound. When the system clock runs
ahead for a render (a dual-boot clock mismatch, a manual change) and is
then set back:

- a "no block" entry stood for the whole skew plus a minute, so Block
  Timer showed "0hr 0m" and Block Reset Timer [Loading] for that long
  (a day, after a day's skew), even with fresh activity in the
  transcripts;
- a block start cached under the fast clock lies in the future, and
  still counted as valid, so elapsed time stayed at 0 and Block Reset
  Timer at 5hr for the skew plus 5 hours.

A "no block" entry is now trusted only while it ends within one
interval, and a cached start only once it has begun; otherwise the
transcripts are scanned again.
The block scan counts only served requests as activity. Besides rejected
requests, it skips lines that don't parse, rows without both input and
output token counts (such as user messages), sidechain rows, and rows
whose timestamp is missing, not a string, or not a valid date. Only the
rejected-request case had a test against the real scan; this one puts a
row of each other kind after a real request and checks that the block
and its last activity come from that request alone.

The row with the invalid date comes first so that, if it were kept, it
would sort ahead of the real request and show up as the last activity.
getAllTimestampsFromFile both read the transcript and decided which rows
count as block activity, so the six reasons to skip a row sat as a run of
continue statements inside the line loop. getActivityTimestamp now
answers that question for one row, returning its timestamp or null with
early returns, and the loop only reads lines and keeps what it returns.
The row shape both functions use gets a name, TranscriptRow.

No change in which rows count.
The block scan counts a row as activity only when it has both an input
and an output token count. The skipped-rows test had a row with input
tokens and no output tokens, but none with output tokens and no input
tokens, so a scan that checked only the output count still passed it.
This adds that row, so the test now fails if either count stops being
required.
@sirmalloc
sirmalloc merged commit 7163627 into sirmalloc:main Oct 29, 2026
3 checks passed
axisrow added a commit to axisrow/ccstatusline that referenced this pull request Oct 29, 2026
sirmalloc#755 live picker preview, sirmalloc#754 fast mode widget, sirmalloc#753 thinking-effort test stub, sirmalloc#752 transcript block fallback fixes, sirmalloc#751 editor keys no longer pause on global install lookup, sirmalloc#750 git cache TTL starts after the run, sirmalloc#749 npx cache skip, sirmalloc#747 Block Usage hides at 100% and swaps with Block Reset Timer)

Conflict policy: upstream infra wins, fork features ported on top. Joins:
sirmalloc#750 stamps the git cache after the run while keeping the fork's execGit
hardening/env snapshot and daemon prefetch bookkeeping; sirmalloc#752's
isApiErrorMessage guard lands in the fork's shared collectTimestampFromLine
(sync + async twins), upstream's duplicate getActivityTimestamp dropped;
sirmalloc#747 adds SESSION_AT_LIMIT alongside the fork's zero hide state
(BlockResetTimer keeps zero + under-limit via isAtSessionLimit, SessionUsage
is no-data/zero/at-limit); sirmalloc#755 replaces the fork's picker window + "Showing
X-Y of N" note with upstream's renderWindowedList/getPickerMaxVisible and
live preview (markers ↑/↓ N more cover the counts); sirmalloc#751's installationState
useMemo replaces the fork's inline lookups, the fork's mainMenuIndex helper
is kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Oct 29, 2026
Upstream: Extra Usage Limit widget (sirmalloc#756), live widget preview while picking (sirmalloc#755), Fast Mode widget (sirmalloc#754), transcript block fallback fixes (sirmalloc#752), editor keys on a global install (sirmalloc#751), git cache TTL (sirmalloc#750), npx cached copy skip (sirmalloc#749), Block Usage hide at 100% (sirmalloc#747), Block Reset Timer hide until 100% (sirmalloc#697), fish-style branch abbreviation (sirmalloc#614), jj detached HEAD (sirmalloc#705), hooks save warning (sirmalloc#703), dev reload (sirmalloc#700), merge ends at a hidden widget (sirmalloc#698), Block Forecast and Block Limit Timer widgets (sirmalloc#699), contributor guide (sirmalloc#706), POSIX path case fix (sirmalloc#707), whitespace-distinct conflict filenames (sirmalloc#709), Quick Start reorder (sirmalloc#715), bunx launch guidance (sirmalloc#714), jj diff summary counts (sirmalloc#711), powerline manual separators (sirmalloc#713), keychain credential by account (sirmalloc#532), Keychain probe timeout (sirmalloc#760), usage cache scoped per CLAUDE_CONFIG_DIR (sirmalloc#266), test isolation (sirmalloc#717, sirmalloc#753), dev-dependency bumps (sirmalloc#745)

Conflicts resolved per the standard-resolution table; fork deletions re-applied.

Hand edits outside conflicts:
- src/utils/global-command-resolution.ts: fork-rename (ccstatusline@ -> ccstatusline-usage@ in upstream's new bunx pattern)
- src/utils/__tests__/global-package-manager.test.ts: fork-rename (package dir ccstatusline-usage in upstream's new tests)
- src/utils/usage-fetch.ts: compat fix - cacheUsageData gained a fetchedAtMs arg (upstream sirmalloc#266); adapted the fork-only fetchFreshUsageData call site
- src/utils/__tests__/usage-fetch.test.ts: compat fixes - cache/lock now hashed per CLAUDE_CONFIG_DIR (usageFilePath helper) in four fork tests; upstream fableUsage renamed to fork's weeklyFableUsage; seedUsageCache takes TokenHome
- src/utils/__tests__/usage-prefetch.test.ts: compat fix - mockFetchUsageData renamed to upstream's mockFetchUsage in cleanly-merged tests
- README.md: took upstream's Quick Start move (sirmalloc#715) and dropped the fork's old copy; fork-rename of its ccstatusline@ specifiers; links to fork-deleted docs (docs/USAGE.md, docs/WINDOWS.md) replaced with the fork's inline text
- CONTRIBUTING.md: links to the fork-deleted docs/DEVELOPMENT.md pointed at the README Development section
- docs/test-retirements.md: ledger entries for the 12 upstream test renames (sirmalloc#697, sirmalloc#698, sirmalloc#700, sirmalloc#705, sirmalloc#713, sirmalloc#760)
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