Skip to article
← COOKD ENGINEERING

Gate on growth, not on time

Our CLI runs after every assistant turn on someone else's laptop, so an idle session has to cost exactly nothing. The trick was gating on how wrong the number is, not how old it is.

SEP 28, 2026 · 7 MIN · COOKD ENGINEERING TEAM
IN THIS PIECE

Our CLI used to report Claude Code usage twice per session. That was a disaster: sessions run for days, the server's access grant expires in under an hour, and a quiet CLI locked people out of the product while they were working.

So we made it report after every assistant turn, through a Claude Code hook. That created the opposite problem at once. The hook runs on a laptop the user is busy using. A full scan of the transcript corpus is about 120 MB of reads and one to three seconds of CPU. Dozens of times an hour, indefinitely, on somebody else's machine, is not acceptable.

The requirement we wrote down was absolute: an idle machine costs exactly zero. No scan, no network, no background work. Not cheap. Zero.

It took two gates. They gate on different things, and neither is a timer.

Gate one: did anything change?

The cheapest question, asked without reading a byte of file content:

export interface ScanSignature {
  fileCount: number;
  totalSize: number;
  maxMtimeMs: number;
}

Three numbers from stat calls over the transcript tree: how many files, how many bytes, and the newest modification time. If that triple matches the one recorded at the last push, nothing has happened and the sync returns immediately:

let state = loadSyncState();
const signature = await scanSignature();
if (!opts.force && state.lastPushedAt && signaturesEqual(signature, state.signature)) {
  return { outcome: 'unchanged', creds };
}

This is not a general change detector, and it does not need to be. Transcripts are append-only: Claude Code adds lines to the current session's file and creates new files for new sessions. Under append-only writes, new content means a new size or mtime. A file rewritten in place to the same length within one timestamp tick would slip past, but nothing here does that, and a missed push is corrected on the next turn.

Deciding to read is not the same as reading less

The comment on that interface took longer to get right than the code:

/**
 * This is a **gating cursor, not an offset cursor**. It decides *whether* to
 * scan; it never changes *what* gets read.
 */

That distinction is the whole safety argument. It is also why the obvious optimisation was rejected.

The obvious optimisation is an offset cursor: remember how far into each file you read and next time read only the new bytes. It is the textbook answer for append-only logs and would slash scan cost. It would also quietly break two things that need the whole corpus at once.

Deduplication. The same message can appear in more than one transcript file. De-duplicating by message ID was measured to prevent a 347-million weighted-token over-count on a single sync. The duplicate of the message you are reading now may live in a file you read three days ago. And the rule is not "keep the first one":

function shouldReplaceDedup(existing: UsageEvent, candidate: UsageEvent): boolean {
  const existingIsSidechain = existing.isSidechain ?? false;
  const candidateIsSidechain = candidate.isSidechain ?? false;
  if (existingIsSidechain && !candidateIsSidechain) return true;
  if (!existingIsSidechain && candidateIsSidechain) return false;
  return (candidate.inputTokens + candidate.outputTokens) > (existing.inputTokens + existing.outputTokens);
}

It prefers the main-session copy over the subagent one, then the one with more tokens. Given part of the corpus, it picks a different winner than it would given all of it. A partial scan does not just see less. It can overwrite correct history with worse numbers.

Calibration. The limit the percentage is measured against comes from rate-limit events across the user's whole history, or failing that from the distribution of every pause ever recorded. Both need all of it.

So gate one reads three numbers per file and, when they move, reads everything. The saving is in deciding whether to work, never in doing less of it. That generalises: before optimising a read path, ask whether the expensive part is the reading or the deciding. Only one of them can change your answers.

Gate two: is the number wrong enough to send?

A changed signature means new tokens exist. It does not mean anyone needs to hear about them.

export const GROWTH_THRESHOLD = 0.05;

export function shouldPush(
  currentWeighted: number,
  limit: number | null | undefined,
  lastPushedWeighted?: number,
): boolean {
  if (lastPushedWeighted === undefined) return true;
  const delta = Math.abs(currentWeighted - lastPushedWeighted);
  if (delta === 0) return false;
  if (!limit || limit <= 0) return true;
  return delta >= limit * GROWTH_THRESHOLD;
}

Push when the window has moved by at least 5% of the user's calibrated limit. Not every five minutes, and not when the last push is N minutes old. The threshold is a fraction of the quantity being reported. The code comment states the consequence:

Gating on growth rather than elapsed time bounds the displayed inaccuracy instead of the staleness, which is what makes a 10-day session behave identically to a 10-minute one.

A time gate bounds how old the number is. Nobody has a requirement about the age of a percentage. They care how wrong it is, and a time gate bounds that only indirectly, through an assumed rate of change that is wrong in both directions.

Someone grinding through a heavy session moves 5% of their limit in minutes and gets pushed constantly, which is right: the number is moving and they are watching it. Someone idling in a long session moves almost nothing in an hour and gets pushed rarely, which is also right: the number on screen is still accurate. Same threshold, opposite behaviour, no special cases.

Three details that are easy to miss

No limit means always push. Without a calibrated limit there is no denominator, so any growth pushes. Being wrong about the limit must not turn into never reporting usage. New installs are exactly where you can least afford to be quiet.

A drop pushes too. The window rolls, so tokens ageing out of it are real information. Hence Math.abs. Report only growth and the server keeps showing a peak the user has already fallen away from, indefinitely, on a machine that has gone quiet.

A failed push must not advance the gate. The first version advanced the watermark unconditionally, under a comment claiming this case was already handled. So a failed push left the CLI believing the server was current, and the queued payload waited for another full 5% of growth before anything retried. On an idle machine that could be hours. The fix commit is blunt: "It now only advances on a push the server accepted."

Measure the decision you deferred

Keeping the full-corpus read is a bet that the scan stays cheap. So the code measures it:

/** Rolling record of scan wall-time, so the ADR's "revisit the cursor at
 *  p95 > 5s" trigger has something to fire on. Most recent last. */
scanMs?: number[];

The last 50 samples, with a p95 computed from them. The decision record names p95 above five seconds as the trigger to revisit the offset cursor.

Rejecting the cursor was a judgement call: whole-corpus visibility is worth more than scan time at current corpus sizes. That judgement will expire on somebody's machine one day. A named threshold with a measurement behind it turns "we decided not to" into "not yet, and here is the number that says when". Most architecture decisions depend on facts that quietly change. Few record which fact.

The first real measurements, on a corpus of about 150 MB, were 3.94 s and 4.20 s: inside the budget, and close enough to it that the trigger is doing real work.

What it cost

  • A cold start always scans. Gate one needs a previous push to compare against, so the first run reads everything. Correct, and it makes the slowest run the first impression.
  • Stat calls on every turn. On a large corpus that is thousands of syscalls even when there is nothing to do. Far cheaper than reading, but it is the real floor under "exactly zero".
  • 5% is a guess. It came from a design decision, not a measurement, and it governs the entire freshness-versus-traffic trade. Nothing tells us yet whether it is right.
  • The signature is blind by construction to content changing without size or mtime changing. Safe under append-only writes, and a silent assumption about a file format we do not own.

What we would do differently

  • Sketch the alternative before the trigger fires. A threshold tells you when to revisit a decision, not what to replace it with. The replacement (here, an incremental dedup index) deserves a design sketch while there is no pressure, so that crossing the line is a scheduled change.
  • Test the watermark on failure from the start. A test that fails the network call and asserts the watermark stays put would have caught the unconditional advance, and the comment claiming otherwise.
  • Measure the threshold. Log the delta at each push and the on-screen error it corrected. A week of that data would say whether 5% is too chatty or too quiet.

Bound the error, not the age

A time-based gate bounds staleness, which is a proxy. A growth-based gate bounds inaccuracy, which is the requirement. Express the threshold as a fraction of the quantity you report, and a ten-day session and a ten-minute one need no special cases.

And keep deciding to work separate from doing the work. The version that reads less is the one that silently changes your answers.

NEXTWe deleted our realtime layer, then tried to break what replaced it →

cookd is the app you can only open when Claude has rate-limited you. What is cookd →

PUBLISHED BY THE COOKD ENGINEERING TEAM