SLOPSHOPPER

chef-station

A chef station tray above the prompt: plan limits, today's spend, the hourly trend, models and projects, and thirteen weeks of activity.

newbandcommandprocesstimer
v0.5.0MITupdated 2026-10-08schalkneethling/claude-chef
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chef-station
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /chef ⎿ chef-station: Chef tray: trend ✻ Claude · [ Usage ] [ Trend ] [ Breakdown ] [ Activity ] [ Context ] Spend by hour Today · $0.00 ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ 00 12 23 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ Claude · [ Usage ] [ Trend ] [ Breakdown ] [ Activity ] [ Context ] Spend by hour Today · $0.00 ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ 00 12 23
README

Chef Station

A Claude Code mod that puts a chef station tray directly above your prompt, so you can keep an eye on the kitchen without leaving the session. The repository is called claude-chef, but the plugin is named chef-station because Claude Code reserves plugin names that begin with claude-.

See it in action

https://github.com/user-attachments/assets/33ad4494-3bfb-48da-80f3-af303e1bfc2a

Stations

The tray has five stations. Press the button for a station, or focus the tray with ctrl+x tab and press its number. You can also type /chef <station>, or just /chef to cycle to the next one. The tray remembers the station you picked across sessions. /chef backfill rescans your Claude Code history. Keyboard shortcuts lists every key and command in one place.

  1. Usage shows your plan's rate-limit windows (the five-hour session and the week) with their reset countdowns, what today has cost in API value along with the token count and the share served from cache, and a "Now" row for the current session: the project, the model, the tokens written, the session cost, how full the context window is, how long the prompt cache stays warm, and a stopwatch for the running turn (once the turn ends, it shows how long the turn took).
  2. Trend draws today's spend hour by hour.
  3. Breakdown ranks today's spend by model and by project.
  4. Activity draws a thirteen-week heat map of tokens per day, with the total, the number of active days, the busiest day and your current streak.
  5. Context shows how the current context window is divided, as /context breaks it down: the system prompt, system tools, MCP tools, custom agents, memory files, skills and messages, then the free space and the auto-compact buffer. A legend below the bar names each category with its tokens and its share of the window. Tool schemas that load on demand sit outside the window, so they are left out. The breakdown is estimated locally, the same way /context estimates it, and refreshes after every turn. Below the legend, Compact (hotkey c) compacts the conversation the way /compact does and says how many tokens it saved. Clear (hotkey x) ends the conversation and starts a new one, as /clear does, after asking you to confirm with y or cancel with n. Neither can run during a turn, so while Claude is working the buttons give way to a short note.

The Context station's colors come from a categorical palette checked with a data-visualization validator against both a light and a dark terminal background, because the tray cannot rely on knowing which one you use. Segments that touch are always colors validated as a pair, including for protanopia and deuteranopia. The colors are handed out in the bar's order to guarantee that, so a category's color can change when another category appears or disappears. A one-cell gap separates segments, free space and the buffer are drawn as textures rather than hues, and the legend names every color, so nothing is told apart by color alone.

Keyboard shortcuts

The station and button keys work only while the tray has focus, so they never interfere with typing a prompt. Focus the tray with ctrl+x tab (or click it), and press Esc to return to the prompt.

| Keys | Where | What it does | | :- | :- | :- | | ctrl+x tab | Anywhere | Focuses the tray. | | Esc | Tray focused | Returns to the prompt. | | ctrl+x ctrl+a | Anywhere | Collapses the tray, as its [-] mark does. | | 1 to 5 | Tray focused | Switches to Usage, Trend, Breakdown, Activity or Context. | | c | Context station | Compacts the conversation. | | x | Context station | Asks to clear the conversation. | | y | Clear confirmation | Clears the conversation and starts a new one. | | n | Clear confirmation | Cancels the clear. |

The tray also answers to /chef at the prompt:

| Command | What it does | | :- | :- | | /chef | Cycles to the next station. | | /chef usage, /chef trend, /chef breakdown, /chef activity, /chef context | Switches to that station. | | /chef backfill | Rescans your Claude Code history. |

The prompt cache countdown

The "Now" row shows "cache warm · ~42m left" while the main conversation's prompt cache is likely still warm, and "cache expired" once it has likely lapsed. "Warm" means the cache entries have likely not reached the end of their lifetime, not that the next prompt is guaranteed a cache read: a read also needs the start of the prompt to match what was cached, so anything that changes earlier content, such as switching models or compacting, misses the cache even while it is warm. An expired cache means the next prompt pays to write the cache again. The countdown is the tray's own estimate, which the tilde marks: Claude Code does not report it. A cache entry lives for a fixed time from the start of the last request that read or wrote it, so the tray restarts the countdown as each request of the main conversation starts (a subagent's requests use a cache of their own). How long an entry lives, five minutes or an hour, depends on how it was written, which only the session's transcript records. The tray reads the end of the transcript as each turn ends, and until then it assumes the shorter five minutes, so it never calls a cold cache warm. A /clear starts the countdown over.

Where the numbers come from

The rate-limit windows, the session cost and the context fill come straight from Claude Code ($.session.usage()), so they match the status line and /cost. Rate-limit windows only appear on a subscription and only after the first reply of a session.

Today's spend, the trend, the breakdown and the activity grid come from two sources that the tray adds together.

The first is what the mod records live. Each time a turn completes, in the main conversation or in a subagent, the mod records the session cost added since the previous turn, along with the tokens the turn reported, the model that answered and the project. Every session stores its turns under a store key of its own (turns:<session id>), and the tray adds all of them up when it reads them, so two sessions running at the same time never overwrite each other's turns. The tray picks up other sessions' spending once a minute, and sessions older than thirteen weeks are removed. The cost attributed to each model is an approximation: when a subagent runs, the parent's requests made before the subagent finished are counted against the subagent's model.

The second is a backfill from the session transcripts Claude Code keeps in ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects). The backfill reads the last thirteen weeks, a few seconds after the first session of each day starts, and again whenever you run /chef backfill. It leaves out every session the tray has already recorded live, so nothing is counted twice. Each response appears on several lines of a transcript, so the backfill counts each response once by its message and request IDs. Transcripts hold token counts but no cost, so the backfill estimates cost from first-party API list prices, including the separate rates for cache reads, five-minute and one-hour cache writes, and fast mode. Those prices live in hooks/transcript.mjs. A model with no known price still counts its tokens, and /chef backfill names it.

A mod can only read files of up to 4 MiB, and long sessions write larger transcripts. So when Node is installed, the mod runs the helper script scripts/backfill.mjs, which streams files of any size and prints the tally as JSON. Without Node, the mod reads the transcripts itself and skips the large ones, and /chef backfill says how many it skipped. A transcript that cannot be read at all, for example because it was removed during the scan, adds nothing rather than part of its usage, and /chef backfill reports the backfill as incomplete and says how many transcripts were affected. The helper also works on its own, which is handy for checking the numbers:

echo '[]' | node scripts/backfill.mjs --projects ~/.claude/projects

The JSON array on standard input lists session IDs to leave out.

Claude Code does not tell plugins which plan you are on, so the plan badge is a setting. Set it in /config under the chef-station rows, for example to Max 5×, or leave it empty to hide the badge.

Install

At the prompt of a Claude Code terminal session, run:

/plugin install chef-station --marketplace schalkneethling/claude-chef

Answer y to add the marketplace, then pick a scope. The user scope makes the tray appear in every session.

Update

Claude Code keeps the copy of the plugin it made when you installed it. Auto-update is off by default for marketplaces outside Anthropic's own, including this one, so new versions do not arrive on their own until you turn it on. To turn it on, run /plugin, open the Marketplaces tab, select chef-station and choose Enable auto-update. Claude Code then refreshes the marketplace in the background shortly after a session starts and updates the plugin on disk.

To update right away instead, fetch the latest marketplace listing and then update the plugin:

claude plugin marketplace update chef-station
claude plugin update chef-station

Either way, a session that is already running keeps the version it loaded. Run /reload-plugins to load the new version there, or start a new session. If you load the mod from a clone with --plugin-dir, a git pull is enough, because Claude Code reads that folder directly.

An update installs the version named in .claude-plugin/plugin.json, so every change meant to reach people who installed the plugin raises that version.

Develop locally

Load the mod straight from your clone for a single session:

claude --plugin-dir /path/to/claude-chef

Claude Code watches the folder in an interactive session, so saving a file reloads the mod. The ledger and the selected station survive a reload. Run with claude --debug to see why a hook was skipped or a drawing was refused.

To test the marketplace install flow against your working copy instead of GitHub, add the folder as a marketplace and install from it. Edits are then picked up with /reload-plugins:

claude plugin marketplace add /path/to/claude-chef
claude plugin install chef-station@chef-station

Check and test

claude plugin validate .
claude plugin test .

validate reads the manifest, the marketplace file and the hooks module the way Claude Code will. test runs the tests/*.test.ts files against the engine. format.test.ts, ledger.test.ts and transcript.test.ts cover the pure formatting, bookkeeping, transcript parsing and pricing. register.test.ts mounts the tray on the terminal and desktop surfaces with a stubbed session, clock, store and helper script.

Once Claude Code has loaded the mod from your folder it writes its type declarations to .claude-plugin/types/, and npx tsc -p . type-checks the mod against them.

Source 9 files
hooks/register.tsx 596 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage, Timer } from "claude-code";
3
4import type {
5  ChefStationBackfill,
6  ChefStationCache,
7  ChefStationContext,
8  ChefStationContextAction,
9  ChefStationLedger,
10  ChefStationLive,
11  ChefStationName,
12} from "../types";
13import { cacheTtlFromTranscript } from "./cache";
14import { describeBackfill, MAX_READ_BYTES, SCAN_TIMEOUT_MS, type Tallied, toBackfill } from "./backfill";
15import { modelName, short } from "./format";
16import { backfillSince, composeLedger, dayKey, emptyLedger, isStale, recordTurn } from "./ledger";
17import { STATIONS, tray } from "./stations";
18import { addTranscript, createTally } from "./transcript.mjs";
19
20/**
21 * Each session stores its own turns under `turns:<session id>`, and the tray
22 * adds every session's up when it reads them, so two sessions never write
23 * the same key. `$.store` has no conditional write to make a shared key safe.
24 */
25const SESSION_KEY_PREFIX = "turns:";
26/** The last scan of the transcripts, which any session may replace whole. */
27const BACKFILL_KEY = "backfill";
28/** The single shared ledger of version 0.2.0, still read so its turns are not lost. */
29const LEGACY_LEDGER_KEY = "ledger";
30const STATION_KEY = "station";
31/** Countdowns and other sessions' spending move while this one is idle. */
32const TICK_MS = 60_000;
33/** The turn's stopwatch moves every second while a turn runs. */
34const TURN_TICK_MS = 1_000;
35/** The daily scan waits until the session has settled in. */
36const SCAN_DELAY_MS = 5_000;
37
38// Held by the host, so the tray survives a hot reload of this file.
39const station = atom({ plugin: "chef-station", key: "station" } as const, "usage" as ChefStationName);
40const ledger = atom({ plugin: "chef-station", key: "ledger" } as const, emptyLedger());
41const live = atom({ plugin: "chef-station", key: "live" } as const, null as ChefStationLive | null);
42const now = atom({ plugin: "chef-station", key: "now" } as const, 0);
43const recordedUsd = atom({ plugin: "chef-station", key: "recordedUsd" } as const, 0);
44const cache = atom({ plugin: "chef-station", key: "cache" } as const, {} as ChefStationCache);
45const contextBreakdown = atom({ plugin: "chef-station", key: "context" } as const, null as ChefStationContext | null);
46const contextAction = atom({ plugin: "chef-station", key: "contextAction" } as const, {
47  isRunning: false,
48  isConfirmingClear: false,
49} as ChefStationContextAction);
50const backfillStatus = atom({ plugin: "chef-station", key: "backfillStatus" } as const, { isRunning: false } as {
51  isRunning: boolean;
52  message?: string;
53});
54
55export const register: Register = (on, options) => {
56  const plan = typeof options.plan === "string" && options.plan.trim() !== "" ? options.plan.trim() : undefined;
57
58  // Turns that complete together (a subagent and its parent) are recorded one
59  // at a time, so neither reads the session cost the other is about to claim.
60  let recording: Promise<unknown> = Promise.resolve();
61  let turnTicker: Timer | undefined;
62
63  on("session.start", async ($, e, next) => {
64    const result = await next(e);
65    const usage = await $.session.usage();
66    const root = await $.session.root();
67    const model = modelName(await $.session.model());
68    const saved = await $.store.get(STATION_KEY);
69
70    await update($, live, () => ({
71      project: basename(root),
72      model,
73      outputTokens: 0,
74      usd: usage.cost?.usd ?? 0,
75      startedAt: usage.startedAt,
76      contextPercent: usage.context.percent,
77      rateLimits: [...usage.rateLimits],
78    }));
79    // Whatever the session cost before this load is already in the ledger, or predates the tray.
80    await update($, recordedUsd, () => usage.cost?.usd ?? 0);
81    await tick($);
82    await refreshContext($).catch(() => undefined);
83
84    if (isStation(saved)) {
85      await update($, station, () => saved);
86    }
87
88    await $.command.register({
89      name: "chef",
90      description: "Show a station of the chef tray, or rescan your Claude Code history with backfill.",
91      argumentHint: "[usage|trend|breakdown|activity|context|backfill]",
92      immediate: true,
93    });
94
95    $.clock.every(TICK_MS, () => {
96      tick($).catch(() => undefined);
97    });
98
99    // Fill in the history from Claude Code's transcripts once a day, in the background.
100    const stored = await read($, ledger);
101    const at = await $.clock.now();
102
103    if (!stored.backfill || dayKey(stored.backfill.scannedAt) !== dayKey(at)) {
104      $.clock.after(SCAN_DELAY_MS, () => {
105        runBackfill($).catch(() => undefined);
106      });
107    }
108
109    return result;
110  });
111
112  // Every model request is a step. A request reads or writes the prompt cache and restarts its
113  // lifetime from the moment it starts, so the main conversation's steps start the countdown.
114  on("turn.step", async function* ($, e, next) {
115    if (!e.agentId) {
116      const at = await $.clock.now();
117      await update($, cache, (before) => ({ ...before, lastRequestAt: at }));
118    }
119
120    return yield* next(e);
121  });
122
123  // The settings hooks' Stop fires as the main conversation's turn ends and names its transcript,
124  // whose latest cache write says whether the cache lives for five minutes or an hour.
125  on("classic.Stop", async ($, e, next) => {
126    const result = await next(e);
127    const ttl = cacheTtlFromTranscript(await readTranscriptTail($, e.transcript_path));
128
129    if (ttl) {
130      await update($, cache, (before) => ({ ...before, ttl }));
131    }
132
133    return result;
134  });
135
136  // A /clear raises no session.start: the conversation ends here, its cost starts over from nothing,
137  // and the new conversation has no cache yet.
138  on("session.end", async ($, e, next) => {
139    const result = await next(e);
140
141    if (e.reason === "clear") {
142      await update($, recordedUsd, () => 0);
143      // The breakdown on screen is the previous conversation's; the next turn measures the new one.
144      await update($, contextBreakdown, () => null);
145      await update($, cache, () => ({}));
146    }
147
148    return result;
149  });
150
151  // Raised for the main conversation's turns only; subagents' runs raise none.
152  on("turn.start", async ($, e, next) => {
153    const at = await $.clock.now();
154    // Read again each turn, so a /model switch shows before the turn completes.
155    const model = modelName(await $.session.model());
156    await update($, live, (before) => (before === null ? before : { ...before, model, turnStartedAt: at }));
157    await update($, now, () => at);
158
159    turnTicker?.cancel();
160    turnTicker = $.clock.every(TURN_TICK_MS, () => {
161      $.clock
162        .now()
163        .then((moment) => update($, now, () => moment))
164        .catch(() => undefined);
165    });
166
167    return next(e);
168  });
169
170  on("session.measure", async ($, e, next) => {
171    await applyMeasure($, e);
172    return next(e);
173  });
174
175  on("turn.complete", async ($, e, next) => {
176    const result = await next(e);
177
178    if (!e.agentId) {
179      turnTicker?.cancel();
180      turnTicker = undefined;
181      await update($, live, (before) =>
182        before === null ? before : { ...before, turnStartedAt: undefined, lastTurnMs: e.durationMs },
183      );
184    }
185
186    recording = recording.then(async () => {
187      const usage = await $.session.usage();
188      const totalUsd = usage.cost?.usd ?? 0;
189      const alreadyRecorded = await read($, recordedUsd);
190      // A total below what was recorded means the session's cost started over (a /clear): all of it is new.
191      const usd = totalUsd < alreadyRecorded ? totalUsd : totalUsd - alreadyRecorded;
192      const current = await read($, live);
193      const model = e.usage ? modelName(e.usage.model) : current?.model ?? "—";
194
195      if (usd === 0 && !e.usage) {
196        return;
197      }
198
199      const at = await $.clock.now();
200      const sessionId = await $.session.id();
201      const sessionKey = `${SESSION_KEY_PREFIX}${sessionId}`;
202      const own = asLedger(await $.store.get(sessionKey));
203      const recorded = recordTurn(own, {
204        at,
205        usd,
206        model,
207        project: current?.project ?? "unknown",
208        sessionId,
209        usage: e.usage && {
210          input: e.usage.input_tokens,
211          output: e.usage.output_tokens,
212          cacheRead: e.usage.cache_read_input_tokens,
213          cacheCreation: e.usage.cache_creation_input_tokens,
214        },
215      });
216
217      await $.store.set(sessionKey, recorded);
218      await refreshLedger($);
219      await update($, recordedUsd, () => totalUsd);
220      await update($, live, (before) => ({
221        ...(before ?? { project: "unknown", model, outputTokens: 0, usd: 0, startedAt: at, rateLimits: [] }),
222        // The "Now" row names the main conversation's model, not a subagent's.
223        model: e.agentId && before ? before.model : model,
224        outputTokens: (before?.outputTokens ?? 0) + (e.usage?.output_tokens ?? 0),
225        usd: totalUsd,
226        contextPercent: usage.context.percent,
227        rateLimits: [...usage.rateLimits],
228      }));
229      await update($, now, () => at);
230    });
231
232    await recording.catch(() => undefined);
233
234    // The window changes with every turn of the main conversation; a subagent has its own.
235    if (!e.agentId) {
236      await refreshContext($).catch(() => undefined);
237    }
238
239    return result;
240  });
241
242  on("command.run", { command: "chef" }, async ($, e) => {
243    const asked = e.args.trim().toLowerCase();
244
245    if (asked === "backfill") {
246      return { text: await runBackfill($) };
247    }
248
249    if (asked === "") {
250      const current = await read($, station);
251      const index = STATIONS.findIndex((entry) => entry.name === current);
252      const following = STATIONS[(index + 1) % STATIONS.length]!.name;
253      await select($, following);
254      return { text: `Chef tray: ${following}` };
255    }
256
257    if (!isStation(asked)) {
258      return {
259        text: `No station called "${asked}". Try ${STATIONS.map((entry) => entry.name).join(", ")}, or backfill to rescan your history.`,
260      };
261    }
262
263    await select($, asked);
264    return { text: `Chef tray: ${asked}` };
265  });
266
267  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
268    if (e.props.hasSurvey) {
269      return next(e);
270    }
271
272    const currentLive = await read($, live);
273    const currentLedger = await read($, ledger);
274    const currentNow = await read($, now);
275
276    // Nothing to show before the session has started.
277    if (currentLive === null) {
278      return next(e);
279    }
280
281    const ui = $.ui.resolve(e);
282
283    return tray({
284      ui,
285      station: await read($, station),
286      ledger: currentLedger,
287      live: currentLive,
288      now: currentNow || currentLive.startedAt,
289      plan,
290      backfillStatus: await read($, backfillStatus),
291      cache: await read($, cache),
292      context: await read($, contextBreakdown),
293      contextAction: await read($, contextAction),
294      onContextAction: (action) => void runContextAction($, action),
295      isWorking: e.props.isWorking,
296      columns: e.props.bodyColumns,
297      onSelect: (name) => void select($, name),
298    });
299  });
300};
301
302/**
303 * Scans Claude Code's transcripts for every session the tray did not record
304 * live and stores the result beside the live turns. Returns a line saying how
305 * it went, for `/chef backfill` and the activity station.
306 */
307async function runBackfill($: EngineInterface): Promise<string> {
308  const status = await read($, backfillStatus);
309
310  if (status.isRunning) {
311    return "Already reading your Claude Code history.";
312  }
313
314  await update($, backfillStatus, () => ({ isRunning: true, message: status.message }));
315  let message: string;
316
317  try {
318    const at = await $.clock.now();
319    const before = await loadLedger($);
320    const options = {
321      since: backfillSince(at),
322      excludeSessions: [...Object.keys(before.liveSessions ?? {}), await $.session.id()],
323    };
324    const projects = await projectsDirectory($);
325    const viaNode = await scanWithNode($, projects, options);
326    const backfill = toBackfill(viaNode ?? (await scanWithFs($, projects, options)), viaNode ? "node" : "fs", at);
327
328    await $.store.set(BACKFILL_KEY, backfill);
329    await refreshLedger($);
330    message = describeBackfill(backfill);
331  } catch (error) {
332    message = `Could not read your Claude Code history: ${error instanceof Error ? error.message : String(error)}`;
333  }
334
335  await update($, backfillStatus, () => ({ isRunning: false, message }));
336  return message;
337}
338
339type ScanOptions = { since: number; excludeSessions: string[] };
340
341async function projectsDirectory($: EngineInterface): Promise<string> {
342  const configured = await $.env.get("CLAUDE_CONFIG_DIR");
343
344  if (configured) {
345    return `${configured}/projects`;
346  }
347
348  const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
349  return `${home}/.claude/projects`;
350}
351
352/** The Node helper streams transcripts of any size; a mod may read 4 MiB at most. */
353async function scanWithNode($: EngineInterface, projects: string, options: ScanOptions): Promise<Tallied | undefined> {
354  try {
355    const { exitCode, stdout, isStdoutTruncated } = await $.process.run(
356      ["node", `${$.plugin.root}/scripts/backfill.mjs`, "--projects", projects, "--since", String(options.since)],
357      { stdin: JSON.stringify(options.excludeSessions), timeoutMs: SCAN_TIMEOUT_MS },
358    );
359
360    if (exitCode !== 0 || isStdoutTruncated) {
361      return undefined;
362    }
363
364    return JSON.parse(stdout) as Tallied;
365  } catch {
366    // Node is not installed, or the scan could not finish: read the files directly instead.
367    return undefined;
368  }
369}
370
371async function scanWithFs($: EngineInterface, projects: string, options: ScanOptions): Promise<Tallied> {
372  const tally = createTally(options);
373  let files = 0;
374  let skippedFiles = 0;
375  let unreadableFiles = 0;
376
377  for (const file of await listTranscripts($, projects)) {
378    files += 1;
379
380    if (file.size > MAX_READ_BYTES) {
381      skippedFiles += 1;
382      continue;
383    }
384
385    let text: string;
386
387    try {
388      text = await $.fs.read(file.path);
389    } catch {
390      // Counted apart from the size-limited skips, so the backfill can say it is incomplete.
391      unreadableFiles += 1;
392      continue;
393    }
394
395    await addTranscript(text.split("\n"), tally);
396  }
397
398  return { ...tally.result(), files, skippedFiles, unreadableFiles };
399}
400
401/** Every .jsonl file under `directory`, subagent transcripts included. */
402async function listTranscripts($: EngineInterface, directory: string): Promise<{ path: string; size: number }[]> {
403  const entries = await $.fs.list(directory).catch(() => []);
404  const found: { path: string; size: number }[] = [];
405
406  for (const entry of entries) {
407    const path = `${directory}/${entry.name}`;
408
409    if (entry.kind === "dir") {
410      found.push(...(await listTranscripts($, path)));
411    } else if (entry.kind === "file" && entry.name.endsWith(".jsonl")) {
412      found.push({ path, size: entry.size });
413    }
414  }
415
416  return found;
417}
418
419/** How much of a transcript's end is enough to find the latest response that wrote the cache. */
420const TRANSCRIPT_TAIL_BYTES = 1024 * 1024;
421
422/**
423 * The end of a transcript: the whole file when the mod may read it (4 MiB at
424 * most), otherwise its last megabyte through `tail`. Empty when neither works,
425 * which leaves the lifetime as it was.
426 */
427async function readTranscriptTail($: EngineInterface, path: string): Promise<string> {
428  try {
429    const { size } = await $.fs.stat(path);
430
431    if (size <= MAX_READ_BYTES) {
432      return await $.fs.read(path);
433    }
434
435    const { exitCode, stdout } = await $.process.run(["tail", "-c", String(TRANSCRIPT_TAIL_BYTES), path]);
436    return exitCode === 0 ? stdout : "";
437  } catch {
438    return "";
439  }
440}
441
442/** Shows a station, remembers it for the next session, and measures the context when that station opens. */
443async function select($: EngineInterface, name: ChefStationName) {
444  await update($, station, () => name);
445  await $.store.set(STATION_KEY, name);
446
447  if (name === "context") {
448    await refreshContext($).catch(() => undefined);
449  }
450}
451
452/** What the Context station's Compact and Clear buttons, and Clear's confirmation, do. */
453async function runContextAction($: EngineInterface, action: "compact" | "clear" | "confirm-clear" | "cancel-clear") {
454  if (action === "clear") {
455    await update($, contextAction, (before) => ({ ...before, isConfirmingClear: true, message: undefined }));
456    return;
457  }
458
459  if (action === "cancel-clear") {
460    await update($, contextAction, (before) => ({ ...before, isConfirmingClear: false }));
461    return;
462  }
463
464  // Two quick presses can both arrive before the redraw hides the buttons. Claiming the
465  // running flag in one read-and-write lets only the first of them start the action.
466  let isClaimed = false;
467  await update($, contextAction, (before) => {
468    isClaimed = !before.isRunning;
469    return isClaimed ? { isRunning: true, isConfirmingClear: false } : before;
470  });
471
472  if (!isClaimed) {
473    return;
474  }
475
476  let message: string | undefined;
477
478  try {
479    if (action === "compact") {
480      const result = await $.session.compact();
481
482      if (result.skip !== undefined) {
483        message = `Compacting was skipped: ${result.skip}`;
484      } else if (result.tokensBefore !== undefined && result.tokensAfter !== undefined) {
485        message = `Compacted from ${short(result.tokensBefore)} to ${short(result.tokensAfter)} tokens.`;
486      } else {
487        message = "Compacted.";
488      }
489    } else {
490      // There is no call for this on $; the button runs /clear as if it were typed.
491      await $.command.run({ command: "clear" });
492      message = "Cleared. A new conversation has started.";
493    }
494  } catch (error) {
495    message = `Could not ${action === "compact" ? "compact" : "clear"}: ${error instanceof Error ? error.message : String(error)}`;
496  }
497
498  await update($, contextAction, () => ({ isRunning: false, isConfirmingClear: false, message }));
499  await refreshContext($).catch(() => undefined);
500}
501
502/**
503 * Measures the context window by category, as /context does. The `summary`
504 * level estimates locally and sends no token-count requests, so it is cheap
505 * enough to run after every turn.
506 */
507async function refreshContext($: EngineInterface) {
508  const { context } = await $.session.usage({ breakdown: "summary" });
509  const breakdown = context.breakdown;
510
511  if (!breakdown) {
512    return;
513  }
514
515  const measured: ChefStationContext = {
516    categories: breakdown.categories.map(({ name, tokens, kind }) => ({ name, tokens, kind })),
517    totalTokens: breakdown.totalTokens,
518    maxTokens: breakdown.rawMaxTokens,
519  };
520
521  await update($, contextBreakdown, () => measured);
522}
523
524/** Moves the tray's clock on and picks up what other sessions have recorded. */
525async function tick($: EngineInterface) {
526  const at = await $.clock.now();
527  await update($, now, () => at);
528  await refreshLedger($);
529}
530
531async function refreshLedger($: EngineInterface) {
532  const composed = await loadLedger($);
533  await update($, ledger, () => composed);
534}
535
536/**
537 * Every session's turns and the last scan, read from the store and added up.
538 * A session whose days have all fallen out of the thirteen weeks is deleted
539 * on the way, so the store does not grow without end.
540 */
541async function loadLedger($: EngineInterface): Promise<ChefStationLedger> {
542  const at = await $.clock.now();
543  const sessions: Record<string, ChefStationLedger> = {};
544
545  for (const key of await $.store.keys()) {
546    if (!key.startsWith(SESSION_KEY_PREFIX) && key !== LEGACY_LEDGER_KEY) {
547      continue;
548    }
549
550    const part = asLedger(await $.store.get(key));
551
552    if (isStale(part, at)) {
553      await $.store.delete(key);
554    } else {
555      sessions[key] = part;
556    }
557  }
558
559  const backfill = asBackfill(await $.store.get(BACKFILL_KEY)) ?? sessions[LEGACY_LEDGER_KEY]?.backfill;
560  return composeLedger(sessions, backfill, at);
561}
562
563async function applyMeasure($: EngineInterface, e: SessionMeasureInput | SessionUsage) {
564  await update($, live, (before) =>
565    before === null
566      ? before
567      : {
568          ...before,
569          usd: e.cost?.usd ?? before.usd,
570          contextPercent: e.context.percent ?? before.contextPercent,
571          rateLimits: [...e.rateLimits],
572        },
573  );
574}
575
576function asLedger(value: unknown): ChefStationLedger {
577  const isLedger =
578    typeof value === "object" && value !== null && (value as ChefStationLedger).version === 1 && typeof (value as ChefStationLedger).days === "object";
579
580  return isLedger ? (value as ChefStationLedger) : emptyLedger();
581}
582
583function asBackfill(value: unknown): ChefStationBackfill | undefined {
584  const isBackfill = typeof value === "object" && value !== null && typeof (value as ChefStationBackfill).days === "object";
585  return isBackfill ? (value as ChefStationBackfill) : undefined;
586}
587
588function isStation(value: unknown): value is ChefStationName {
589  return STATIONS.some((entry) => entry.name === value);
590}
591
592function basename(path: string): string {
593  const parts = path.split(/[\\/]/).filter(Boolean);
594  return parts[parts.length - 1] ?? path;
595}
596
hooks/cache.ts 83 lines
1import type { ChefStationCache, ChefStationCacheTtl } from "../types";
2import { countdown } from "./format";
3
4const TTL_MS: Record<ChefStationCacheTtl, number> = { "5m": 5 * 60_000, "1h": 60 * 60_000 };
5
6export type CacheStatus = { state: "warm"; remainingMs: number } | { state: "expired" } | { state: "none" };
7
8/**
9 * The lifetime of the main conversation's latest prompt-cache write, read from
10 * the end of its transcript: `1h` or `5m`, or undefined when no write shows.
11 *
12 * Responses that only read the cache are skipped, since a read keeps the
13 * lifetime its entry was written with. So are subagents' responses, whose
14 * cache is their own. A text cut from the middle of a file may begin with a
15 * partial line, which does not parse and is skipped too.
16 */
17export function cacheTtlFromTranscript(text: string): ChefStationCacheTtl | undefined {
18  const lines = text.split("\n");
19
20  for (let index = lines.length - 1; index >= 0; index--) {
21    const line = lines[index]!;
22
23    if (!line.includes('"usage"') || !line.includes('"assistant"')) {
24      continue;
25    }
26
27    let record;
28
29    try {
30      record = JSON.parse(line);
31    } catch {
32      continue;
33    }
34
35    const usage = record?.message?.usage;
36
37    if (record.type !== "assistant" || record.isSidechain === true || !usage) {
38      continue;
39    }
40
41    const byTtl = usage.cache_creation;
42
43    if ((byTtl?.ephemeral_1h_input_tokens ?? 0) > 0) {
44      return "1h";
45    }
46
47    if ((byTtl?.ephemeral_5m_input_tokens ?? 0) > 0) {
48      return "5m";
49    }
50
51    // Older responses report cache writes without the breakdown; those are five-minute writes.
52    if (!byTtl && (usage.cache_creation_input_tokens ?? 0) > 0) {
53      return "5m";
54    }
55  }
56
57  return undefined;
58}
59
60/**
61 * Whether the main conversation's prompt cache is likely still warm, and for
62 * how long. An entry lives for its lifetime from the start of the last request
63 * that read or wrote it; with no lifetime known, the shorter five minutes is
64 * assumed, so the tray never calls a cold cache warm.
65 */
66export function cacheStatus(cache: ChefStationCache, now: number): CacheStatus {
67  if (cache.lastRequestAt === undefined) {
68    return { state: "none" };
69  }
70
71  const remainingMs = cache.lastRequestAt + TTL_MS[cache.ttl ?? "5m"] - now;
72  return remainingMs > 0 ? { state: "warm", remainingMs } : { state: "expired" };
73}
74
75/** The Now row's words for the cache: an estimate while warm, so it says so with a tilde. */
76export function cacheLabel(status: CacheStatus): string | undefined {
77  if (status.state === "warm") {
78    return `cache warm · ${status.remainingMs < 60_000 ? "<1m" : `~${countdown(status.remainingMs)}`} left`;
79  }
80
81  return status.state === "expired" ? "cache expired" : undefined;
82}
83
hooks/backfill.ts 66 lines
1import type { ChefStationBackfill, ChefStationDay } from "../types";
2import { modelName, money } from "./format";
3
4/** The most a mod may read in one `$.fs.read`. */
5export const MAX_READ_BYTES = 4 * 1024 * 1024;
6/** A first scan of thirteen weeks of transcripts can take a while. */
7export const SCAN_TIMEOUT_MS = 10 * 60_000;
8
9/** What a scan tallied, from the Node helper or from the mod's own reads. */
10export type Tallied = {
11  days: Record<string, ChefStationDay>;
12  unpricedModels: string[];
13  files: number;
14  /** Transcripts over 4 MiB that the mod could not read itself. */
15  skippedFiles: number;
16  /** Transcripts whose read failed; absent from helpers older than 0.3.1. */
17  unreadableFiles?: number;
18};
19
20/** Turns a tally into the backfill the ledger keeps, with models named as people say them. */
21export function toBackfill(tallied: Tallied, source: ChefStationBackfill["source"], scannedAt: number): ChefStationBackfill {
22  return {
23    days: Object.fromEntries(Object.entries(tallied.days).map(([key, day]) => [key, withModelNames(day)])),
24    scannedAt,
25    source,
26    files: tallied.files,
27    skippedFiles: tallied.skippedFiles,
28    unreadableFiles: tallied.unreadableFiles ?? 0,
29    unpricedModels: tallied.unpricedModels,
30  };
31}
32
33/** A line saying what a scan found, for `/chef backfill` and the activity station. */
34export function describeBackfill(backfill: ChefStationBackfill): string {
35  const usd = Object.values(backfill.days).reduce((sum, day) => sum + day.usd, 0);
36  const files = `${backfill.files} ${backfill.files === 1 ? "transcript" : "transcripts"}`;
37  const notes = [
38    `Read ${files}${backfill.source === "node" ? " with Node" : ""}: ${money(usd)} across ${Object.keys(backfill.days).length} days.`,
39  ];
40
41  if (backfill.skippedFiles > 0) {
42    notes.push(`${backfill.skippedFiles} over 4 MiB were skipped; install Node to include them.`);
43  }
44
45  if ((backfill.unreadableFiles ?? 0) > 0) {
46    notes.push(`Incomplete: ${backfill.unreadableFiles} could not be read, so their usage is missing.`);
47  }
48
49  if (backfill.unpricedModels.length > 0) {
50    notes.push(`No price for ${backfill.unpricedModels.join(", ")}; their tokens count but their cost does not.`);
51  }
52
53  return notes.join(" ");
54}
55
56function withModelNames(day: ChefStationDay): ChefStationDay {
57  const models: Record<string, number> = {};
58
59  for (const [id, usd] of Object.entries(day.models)) {
60    const name = modelName(id);
61    models[name] = (models[name] ?? 0) + usd;
62  }
63
64  return { ...day, models };
65}
66
hooks/format.ts 113 lines
1const EIGHTHS = " ▁▂▃▄▅▆▇█";
2
3export function short(n: number): string {
4  const units: [number, string][] = [
5    [1e9, "B"],
6    [1e6, "M"],
7    [1e3, "k"],
8  ];
9
10  for (const [size, suffix] of units) {
11    if (n >= size) {
12      return `${+(n / size).toFixed(1)}${suffix}`;
13    }
14  }
15
16  return String(Math.round(n));
17}
18
19export function money(usd: number): string {
20  return `$${usd.toFixed(2)}`;
21}
22
23/**
24 * A model id as people say it: `claude-opus-5-5` is "Opus 5.5",
25 * `claude-3-5-sonnet-20241022` is "Sonnet 3.5". A context-window suffix such
26 * as `[1m]`, which `/model` shows, is dropped. Anything else is kept as given.
27 */
28export function modelName(model: string): string {
29  const id = model.replace(/\[[^\]]*\]$/, "");
30  const familyFirst = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(id);
31
32  if (familyFirst) {
33    const [, family = "", major = "", minor] = familyFirst;
34    return `${capitalize(family)} ${minor ? `${major}.${minor}` : major}`;
35  }
36
37  const versionFirst = /^claude-(\d+)(?:-(\d{1,2}))?-(opus|sonnet|haiku)(?:-\d{8})?$/.exec(id);
38
39  if (versionFirst) {
40    const [, major = "", minor, family = ""] = versionFirst;
41    return `${capitalize(family)} ${minor ? `${major}.${minor}` : major}`;
42  }
43
44  return model;
45}
46
47function capitalize(word: string): string {
48  return word.charAt(0).toUpperCase() + word.slice(1);
49}
50
51/** Time until a reset: "1d 23h", "2h 10m", "9m". */
52export function countdown(ms: number): string {
53  if (ms <= 0) {
54    return "now";
55  }
56
57  const minutes = Math.floor(ms / 60_000);
58  const hours = Math.floor(minutes / 60);
59  const days = Math.floor(hours / 24);
60
61  if (days > 0) {
62    return `${days}d ${hours % 24}h`;
63  }
64
65  if (hours > 0) {
66    return `${hours}h ${minutes % 60}m`;
67  }
68
69  return `${minutes}m`;
70}
71
72/** A stopwatch reading: "0:33", "12:05", and past an hour "1:02:03". */
73export function elapsed(ms: number): string {
74  const seconds = Math.max(0, Math.floor(ms / 1_000));
75  const minutes = Math.floor(seconds / 60);
76  const hours = Math.floor(minutes / 60);
77  const pad = (value: number) => String(value).padStart(2, "0");
78
79  if (hours > 0) {
80    return `${hours}:${pad(minutes % 60)}:${pad(seconds % 60)}`;
81  }
82
83  return `${minutes}:${pad(seconds % 60)}`;
84}
85
86/** A horizontal meter `width` cells wide, split so each part can take its own color. */
87export function bar(percent: number, width: number): { filled: string; empty: string } {
88  const cells = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width);
89  return { filled: "█".repeat(cells), empty: "░".repeat(width - cells) };
90}
91
92/**
93 * Columns of eighth blocks, `rows` tall, one character per value, returned top
94 * row first. The largest value fills the height; any value above zero shows
95 * at least one eighth so a quiet hour is still visible.
96 */
97export function verticalBars(values: number[], rows: number): string[] {
98  const top = Math.max(...values, 0);
99  const levels = rows * 8;
100  const heights = values.map((value) => {
101    if (value <= 0 || top === 0) {
102      return 0;
103    }
104
105    return Math.max(1, Math.round((value / top) * levels));
106  });
107
108  return Array.from({ length: rows }, (_, rowFromTop) => {
109    const floor = (rows - 1 - rowFromTop) * 8;
110    return heights.map((height) => EIGHTHS[Math.min(Math.max(height - floor, 0), 8)]).join("");
111  });
112}
113
hooks/ledger.ts 233 lines
1import type { ChefStationBackfill, ChefStationDay, ChefStationLedger } from "../types";
2import { dayKey } from "./transcript.mjs";
3
4export { dayKey };
5
6/** How far back the ledger remembers: the thirteen weeks the activity grid shows. */
7export const WEEKS = 13;
8
9export type TurnUsage = {
10  input: number;
11  output: number;
12  cacheRead: number;
13  cacheCreation: number;
14};
15
16export type TurnRecord = {
17  /** When the turn completed, in milliseconds since the epoch. */
18  at: number;
19  /** What the turn cost, in US dollars. */
20  usd: number;
21  model: string;
22  project: string;
23  /** The session the turn ran in, so a scan of the transcripts does not count it again. */
24  sessionId: string;
25  usage?: TurnUsage;
26};
27
28export type Ranked = { name: string; usd: number };
29
30export type Activity = {
31  /** Thirteen columns of Monday to Sunday; `null` for a day still ahead. */
32  weeks: (number | null)[][];
33  totalTokens: number;
34  activeDays: number;
35  busiest?: { key: string; tokens: number };
36  /** Consecutive active days up to today, or up to yesterday before today's first turn. */
37  streak: number;
38};
39
40export function emptyLedger(): ChefStationLedger {
41  return { version: 1, days: {} };
42}
43
44function emptyDay(): ChefStationDay {
45  return {
46    usd: 0,
47    tokens: 0,
48    cacheReadTokens: 0,
49    inputTokens: 0,
50    hours: new Array(24).fill(0),
51    models: {},
52    projects: {},
53  };
54}
55
56/** Midnight, local time, `days` days before the day of `ms`. */
57function startOfDay(ms: number, daysBack = 0): number {
58  const date = new Date(ms);
59  return new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysBack).getTime();
60}
61
62/**
63 * Adds one completed turn to the ledger and drops the days that have fallen
64 * out of the thirteen weeks. Returns a new ledger; the one passed in is left as it was.
65 */
66export function recordTurn(ledger: ChefStationLedger, turn: TurnRecord): ChefStationLedger {
67  const key = dayKey(turn.at);
68  const before = ledger.days[key] ?? emptyDay();
69  const usage = turn.usage ?? { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
70  const inputTokens = usage.input + usage.cacheRead + usage.cacheCreation;
71  const hour = new Date(turn.at).getHours();
72  const hours = before.hours.map((usd, index) => (index === hour ? usd + turn.usd : usd));
73
74  const day: ChefStationDay = {
75    usd: before.usd + turn.usd,
76    tokens: before.tokens + inputTokens + usage.output,
77    cacheReadTokens: before.cacheReadTokens + usage.cacheRead,
78    inputTokens: before.inputTokens + inputTokens,
79    hours,
80    models: addTo(before.models, turn.model, turn.usd),
81    projects: addTo(before.projects, turn.project, turn.usd),
82  };
83
84  return prune(
85    {
86      ...ledger,
87      days: { ...ledger.days, [key]: day },
88      liveSessions: { ...ledger.liveSessions, [turn.sessionId]: key },
89    },
90    turn.at,
91  );
92}
93
94/** Puts a fresh scan of the transcripts in place of the last one. */
95export function withBackfill(ledger: ChefStationLedger, backfill: ChefStationBackfill): ChefStationLedger {
96  return prune({ ...ledger, backfill }, backfill.scannedAt);
97}
98
99/** The earliest moment a scan needs to read: the first day the ledger keeps. */
100export function backfillSince(now: number): number {
101  return startOfDay(now, WEEKS * 7 - 1);
102}
103
104function addTo(record: Record<string, number>, name: string, usd: number): Record<string, number> {
105  return { ...record, [name]: (record[name] ?? 0) + usd };
106}
107
108function prune(ledger: ChefStationLedger, now: number): ChefStationLedger {
109  const oldest = dayKey(backfillSince(now));
110  const isKept = ([key]: [string, unknown]) => key >= oldest;
111  const keepDays = (days: Record<string, ChefStationDay>) => Object.fromEntries(Object.entries(days).filter(isKept));
112  // Sessions are keyed by id with their last day as the value.
113  const liveSessions = Object.fromEntries(Object.entries(ledger.liveSessions ?? {}).filter(([, key]) => key >= oldest));
114
115  return {
116    version: 1,
117    days: keepDays(ledger.days),
118    liveSessions,
119    ...(ledger.backfill ? { backfill: { ...ledger.backfill, days: keepDays(ledger.backfill.days) } } : {}),
120  };
121}
122
123/** One day as the tray shows it: what was recorded live plus what the last scan found. */
124function dayOf(ledger: ChefStationLedger, key: string): ChefStationDay | undefined {
125  const live = ledger.days[key];
126  const scanned = ledger.backfill?.days[key];
127
128  return live && scanned ? addDays(live, scanned) : (live ?? scanned);
129}
130
131function addDays(first: ChefStationDay, second: ChefStationDay): ChefStationDay {
132  return {
133    usd: first.usd + second.usd,
134    tokens: first.tokens + second.tokens,
135    cacheReadTokens: first.cacheReadTokens + second.cacheReadTokens,
136    inputTokens: first.inputTokens + second.inputTokens,
137    hours: first.hours.map((usd, hour) => usd + (second.hours[hour] ?? 0)),
138    models: sumRecords(first.models, second.models),
139    projects: sumRecords(first.projects, second.projects),
140  };
141}
142
143/**
144 * The ledger the tray reads, put together from the turns each session stored
145 * under a key of its own and the last scan of the transcripts. Each session
146 * writing only its own key keeps two sessions from overwriting each other.
147 */
148export function composeLedger(
149  sessions: Record<string, ChefStationLedger>,
150  backfill: ChefStationBackfill | undefined,
151  now: number,
152): ChefStationLedger {
153  const days: Record<string, ChefStationDay> = {};
154  const liveSessions: Record<string, string> = {};
155
156  for (const part of Object.values(sessions)) {
157    for (const [key, day] of Object.entries(part.days)) {
158      const before = days[key];
159      days[key] = before ? addDays(before, day) : day;
160    }
161
162    Object.assign(liveSessions, part.liveSessions);
163  }
164
165  return prune({ version: 1, days, liveSessions, ...(backfill ? { backfill } : {}) }, now);
166}
167
168/** Whether every day a session stored has fallen out of the thirteen weeks. */
169export function isStale(ledger: ChefStationLedger, now: number): boolean {
170  return Object.keys(prune(ledger, now).days).length === 0;
171}
172
173function sumRecords(first: Record<string, number>, second: Record<string, number>): Record<string, number> {
174  return Object.entries(second).reduce((sum, [name, usd]) => addTo(sum, name, usd), first);
175}
176
177export function today(ledger: ChefStationLedger, now: number): ChefStationDay {
178  return dayOf(ledger, dayKey(now)) ?? emptyDay();
179}
180
181export function hourly(ledger: ChefStationLedger, now: number): number[] {
182  return today(ledger, now).hours;
183}
184
185/** Entries sorted by cost, most expensive first, at most `limit` of them. */
186export function ranked(record: Record<string, number>, limit = Infinity): Ranked[] {
187  return Object.entries(record)
188    .map(([name, usd]) => ({ name, usd }))
189    .sort((a, b) => b.usd - a.usd)
190    .slice(0, limit);
191}
192
193export function activity(ledger: ChefStationLedger, now: number): Activity {
194  // getDay() counts from Sunday; the grid counts from Monday.
195  const weekday = (new Date(now).getDay() + 6) % 7;
196  const daysShown = (WEEKS - 1) * 7 + weekday + 1;
197  const weeks: (number | null)[][] = Array.from({ length: WEEKS }, () => new Array(7).fill(null));
198
199  let totalTokens = 0;
200  let activeDays = 0;
201  let busiest: Activity["busiest"];
202
203  for (let index = 0; index < daysShown; index++) {
204    const key = dayKey(startOfDay(now, daysShown - 1 - index));
205    const tokens = dayOf(ledger, key)?.tokens ?? 0;
206    weeks[Math.floor(index / 7)]![index % 7] = tokens;
207
208    if (tokens > 0) {
209      totalTokens += tokens;
210      activeDays += 1;
211
212      if (busiest === undefined || tokens > busiest.tokens) {
213        busiest = { key, tokens };
214      }
215    }
216  }
217
218  return { weeks, totalTokens, activeDays, busiest, streak: streak(ledger, now) };
219}
220
221function streak(ledger: ChefStationLedger, now: number): number {
222  const isActive = (daysBack: number) => (dayOf(ledger, dayKey(startOfDay(now, daysBack)))?.tokens ?? 0) > 0;
223  let daysBack = isActive(0) ? 0 : 1;
224  let count = 0;
225
226  while (daysBack <= WEEKS * 7 && isActive(daysBack)) {
227    count += 1;
228    daysBack += 1;
229  }
230
231  return count;
232}
233
hooks/stations.tsx 457 lines
1import type { Elements, RenderChildren } from "claude-code";
2
3import type { ChefStationCache, ChefStationContext, ChefStationContextAction, ChefStationLedger, ChefStationLive, ChefStationName } from "../types";
4import { cacheLabel, cacheStatus } from "./cache";
5import { allocateCells, contextSegments } from "./context";
6import { bar, countdown, elapsed, money, short, verticalBars } from "./format";
7import { activity, hourly, ranked, today } from "./ledger";
8
9/** The elements a station draws with; the terminal's and the desktop's tables both have them. */
10export type Ui = Pick<Elements["terminal"], "Box" | "Text" | "Button">;
11
12export type TrayView = {
13  ui: Ui;
14  station: ChefStationName;
15  ledger: ChefStationLedger;
16  live?: ChefStationLive;
17  now: number;
18  plan?: string;
19  /** Whether a scan of the transcripts is running, and what the last one said. */
20  backfillStatus?: { isRunning: boolean; message?: string };
21  /** The main conversation's prompt cache: when it was last used, and how long its entries live. */
22  cache?: ChefStationCache;
23  /** The context window by category; null until the first measurement. */
24  context?: ChefStationContext | null;
25  /** Whether Clear or Compact is running, whether Clear awaits confirmation, and what the last one said. */
26  contextAction?: ChefStationContextAction;
27  onContextAction?: (action: "compact" | "clear" | "confirm-clear" | "cancel-clear") => void;
28  isWorking: boolean;
29  columns: number;
30  onSelect: (station: ChefStationName) => void;
31};
32
33export const STATIONS: { name: ChefStationName; label: string; hotkey: string }[] = [
34  { name: "usage", label: "Usage", hotkey: "1" },
35  { name: "trend", label: "Trend", hotkey: "2" },
36  { name: "breakdown", label: "Breakdown", hotkey: "3" },
37  { name: "activity", label: "Activity", hotkey: "4" },
38  { name: "context", label: "Context", hotkey: "5" },
39];
40
41const RATE_LIMIT_LABELS: Record<string, string> = {
42  five_hour: "Session",
43  seven_day: "Week",
44  seven_day_opus: "Week Opus",
45  seven_day_sonnet: "Week Sonnet",
46  spend_limit: "Spend",
47};
48
49const LABEL_WIDTH = 10;
50const ACCENT = "claude";
51const MUTED = "subtle";
52
53export function tray(view: TrayView) {
54  const { Box } = view.ui;
55
56  return (
57    <Box flexDirection="column" paddingX={1}>
58      {header(view)}
59      {station(view)}
60    </Box>
61  );
62}
63
64function header({ ui, station, plan, onSelect }: TrayView) {
65  const { Box, Text, Button } = ui;
66
67  return (
68    <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
69      <Text color={ACCENT} bold>
70        ✻ Claude
71      </Text>
72      {plan ? <Text color={ACCENT}>{plan}</Text> : null}
73      <Text dimColor>·</Text>
74      {STATIONS.map((entry) => (
75        <Button
76          key={`station-${entry.name}`}
77          label={entry.label}
78          hotkey={entry.hotkey}
79          variant={entry.name === station ? "primary" : "secondary"}
80          onPress={() => onSelect(entry.name)}
81        />
82      ))}
83    </Box>
84  );
85}
86
87/** The body of the station the tray shows. */
88function station(view: TrayView) {
89  switch (view.station) {
90    case "trend":
91      return trend(view);
92    case "breakdown":
93      return breakdown(view);
94    case "activity":
95      return activityGrid(view);
96    case "context":
97      return contextWindow(view);
98    default:
99      return usage(view);
100  }
101}
102
103function row(ui: Ui, label: string, ...children: RenderChildren[]) {
104  const { Box, Text } = ui;
105
106  return (
107    <Box flexDirection="row">
108      <Box width={LABEL_WIDTH} flexShrink={0}>
109        <Text dimColor>{label}</Text>
110      </Box>
111      <Box flexDirection="row" flexShrink={1}>
112        {children}
113      </Box>
114    </Box>
115  );
116}
117
118/**
119 * The Usage station: the plan's rate-limit windows, today's spend, and the
120 * Now row for this session, its prompt cache countdown included.
121 */
122function usage({ ui, ledger, live, now, isWorking, columns, cache }: TrayView) {
123  const { Box, Text } = ui;
124  const day = today(ledger, now);
125  const cachePercent = day.inputTokens > 0 ? Math.round((day.cacheReadTokens / day.inputTokens) * 100) : 0;
126  const meterWidth = Math.min(Math.max(columns - LABEL_WIDTH - 20, 10), 40);
127
128  const limits = (live?.rateLimits ?? []).map((limit) => {
129    const meter = bar(limit.percentUsed, meterWidth);
130    const color = limit.percentUsed >= 90 ? "error" : limit.percentUsed >= 75 ? "warning" : ACCENT;
131    const resetsIn = limit.resetsAt ? Date.parse(limit.resetsAt) - now : undefined;
132
133    return row(
134      ui,
135      RATE_LIMIT_LABELS[limit.kind] ?? limit.kind,
136      <Text color={color}>{meter.filled}</Text>,
137      <Text color={MUTED}>{meter.empty}</Text>,
138      <Text bold>{` ${Math.round(limit.percentUsed)}%`.padStart(5)}</Text>,
139      resetsIn === undefined || Number.isNaN(resetsIn) ? null : <Text dimColor>{`  ↻ ${countdown(resetsIn)}`}</Text>,
140    );
141  });
142
143  const todayRow = row(
144    ui,
145    "Today",
146    <Text bold>{money(day.usd)}</Text>,
147    <Text dimColor>{` API value · ${short(day.tokens)} tokens · ${cachePercent}% from cache`}</Text>,
148  );
149
150  // The running turn's stopwatch, or how long the last turn took once it is done.
151  const turnTime =
152    live?.turnStartedAt !== undefined ? (
153      <Text color={ACCENT} bold>{`  ${elapsed(now - live.turnStartedAt)}`}</Text>
154    ) : live?.lastTurnMs !== undefined ? (
155      <Text dimColor>{`  last turn ${elapsed(live.lastTurnMs)}`}</Text>
156    ) : null;
157
158  const cacheText = cacheLabel(cacheStatus(cache ?? {}, now));
159
160  const nowRow = live
161    ? row(
162        ui,
163        "Now",
164        <Text color={isWorking ? ACCENT : MUTED}>● </Text>,
165        <Text bold wrap="truncate-end">
166          {live.project}
167        </Text>,
168        <Text dimColor wrap="truncate-end">
169          {[
170            ` · ${live.model}`,
171            ` · ${short(live.outputTokens)} written`,
172            ` · ${money(live.usd)}`,
173            live.contextPercent === undefined ? "" : ` · context ${Math.round(live.contextPercent)}%`,
174          ].join("")}
175        </Text>,
176        cacheText ? <Text dimColor>{` · ${cacheText}`}</Text> : null,
177        turnTime,
178      )
179    : null;
180
181  return (
182    <Box flexDirection="column" marginTop={1}>
183      {limits}
184      {todayRow}
185      {nowRow}
186    </Box>
187  );
188}
189
190function trend({ ui, ledger, now, columns }: TrayView) {
191  const { Box, Text } = ui;
192  const hours = hourly(ledger, now);
193  const total = hours.reduce((sum, usd) => sum + usd, 0);
194  // Two cells and a gap per hour when there is room, then one and a gap, then one.
195  const barWidth = columns >= 75 ? 2 : 1;
196  const gap = columns >= 50 ? 1 : 0;
197  const rows = verticalBars(hours, 4);
198  const currentHour = new Date(now).getHours();
199  const step = barWidth + gap;
200
201  const barRows = rows.map((line, rowIndex) => {
202    const isBaseline = rowIndex === rows.length - 1;
203
204    return (
205      <Box flexDirection="row">
206        {[...line].map((glyph, hour) => {
207          const isEmptyBaseline = isBaseline && glyph === " " && hour <= currentHour;
208          const cell = (isEmptyBaseline ? "▁" : glyph).repeat(barWidth) + " ".repeat(gap);
209
210          return <Text color={isEmptyBaseline ? MUTED : ACCENT}>{cell}</Text>;
211        })}
212      </Box>
213    );
214  });
215
216  const axisWidth = 24 * step;
217  const axis = "00".padEnd(12 * step) + "12".padEnd(axisWidth - 12 * step - 2) + "23";
218
219  return (
220    <Box flexDirection="column" marginTop={1}>
221      <Box flexDirection="row" width={axisWidth} justifyContent="space-between">
222        <Text bold>Spend by hour</Text>
223        <Text dimColor>{`Today · ${money(total)}`}</Text>
224      </Box>
225      {barRows}
226      <Text dimColor>{axis}</Text>
227    </Box>
228  );
229}
230
231function breakdown({ ui, ledger, now, columns }: TrayView) {
232  const { Box, Text } = ui;
233  const day = today(ledger, now);
234  const isSideBySide = columns >= 90;
235  const listWidth = isSideBySide ? Math.floor((columns - 2) / 2) : columns;
236
237  const list = (title: string, record: Record<string, number>) => {
238    const entries = ranked(record, 5);
239    const top = entries[0]?.usd ?? 0;
240    const meterWidth = Math.min(16, Math.max(6, listWidth - 34));
241
242    return (
243      <Box flexDirection="column" width={listWidth}>
244        <Text bold>{title}</Text>
245        {entries.length === 0 ? <Text dimColor>Nothing cooked yet today</Text> : null}
246        {entries.map((entry) => {
247          const meter = bar(top > 0 ? (entry.usd / top) * 100 : 0, meterWidth);
248
249          return (
250            <Box flexDirection="row" columnGap={1}>
251              <Box width={20} flexShrink={1}>
252                <Text wrap="truncate-middle">{entry.name}</Text>
253              </Box>
254              <Text color={ACCENT}>{meter.filled}</Text>
255              <Text>{meter.empty.replaceAll("░", " ")}</Text>
256              <Text dimColor>{money(entry.usd).padStart(8)}</Text>
257            </Box>
258          );
259        })}
260      </Box>
261    );
262  };
263
264  return (
265    <Box flexDirection={isSideBySide ? "row" : "column"} columnGap={2} rowGap={1} marginTop={1}>
266      {list("Models", day.models)}
267      {list("Projects", day.projects)}
268    </Box>
269  );
270}
271
272const SHADES = ["░░", "▒▒", "▓▓", "██"];
273const WEEKDAYS = ["Mon", "", "Wed", "", "Fri", "", "Sun"];
274
275function activityGrid({ ui, ledger, now, columns, backfillStatus }: TrayView) {
276  const { Box, Text } = ui;
277  const grid = activity(ledger, now);
278  const top = Math.max(...grid.weeks.flat().map((tokens) => tokens ?? 0), 0);
279
280  const cell = (tokens: number | null) => {
281    if (tokens === null) {
282      return <Text>{"  "}</Text>;
283    }
284
285    if (tokens === 0 || top === 0) {
286      return <Text color={MUTED}>{"· "}</Text>;
287    }
288
289    const level = Math.min(SHADES.length - 1, Math.ceil((tokens / top) * SHADES.length) - 1);
290    return <Text color={ACCENT}>{SHADES[level]}</Text>;
291  };
292
293  const rows = WEEKDAYS.map((weekday, dayIndex) => (
294    <Box flexDirection="row">
295      <Box width={4}>
296        <Text dimColor>{weekday}</Text>
297      </Box>
298      {grid.weeks.map((week) => cell(week[dayIndex] ?? null))}
299    </Box>
300  ));
301
302  const busiest = grid.busiest
303    ? `${new Date(`${grid.busiest.key}T12:00:00`).toLocaleDateString("en-US", { day: "2-digit", month: "short" })} · ${short(grid.busiest.tokens)}`
304    : "—";
305
306  const stats = (
307    <Box flexDirection="column" marginLeft={columns >= 60 ? 3 : 0}>
308      <Text bold>{`${short(grid.totalTokens)} tokens`}</Text>
309      <Text dimColor>{`${grid.weeks.length} weeks`}</Text>
310      <Text> </Text>
311      <Text>
312        <Text dimColor>Active days </Text>
313        {String(grid.activeDays)}
314      </Text>
315      <Text>
316        <Text dimColor>Busiest day </Text>
317        {busiest}
318      </Text>
319      <Text>
320        <Text dimColor>Streak </Text>
321        <Text color={ACCENT}>{`${grid.streak} ${grid.streak === 1 ? "day" : "days"}`}</Text>
322        {today(ledger, now).tokens > 0 ? "" : " (cook today to keep it)"}
323      </Text>
324    </Box>
325  );
326
327  const history = backfillStatus?.isRunning
328    ? "Reading your Claude Code history…"
329    : ledger.backfill
330      ? `History from ${ledger.backfill.files} transcripts, estimated at list prices. /chef backfill rescans it.`
331      : "Only turns since the tray was installed. /chef backfill reads your earlier history.";
332
333  return (
334    <Box flexDirection="column" marginTop={1}>
335      <Box flexDirection={columns >= 60 ? "row" : "column"}>
336        <Box flexDirection="column">{rows}</Box>
337        {stats}
338      </Box>
339      <Text dimColor wrap="wrap">
340        {history}
341      </Text>
342    </Box>
343  );
344}
345
346/** Each legend entry's width, so entries line up in columns as they wrap. */
347const LEGEND_ENTRY_WIDTH = 36;
348
349/**
350 * The Context station: the window as one bar of categories, a legend that
351 * names each color with its tokens and share, and the Compact and Clear buttons.
352 */
353function contextWindow({ ui, context, columns, isWorking, contextAction, onContextAction }: TrayView) {
354  const { Box, Text } = ui;
355
356  if (!context) {
357    return (
358      <Box marginTop={1}>
359        <Text dimColor>Measuring the context window…</Text>
360      </Box>
361    );
362  }
363
364  const segments = contextSegments(context.categories);
365  // A one-cell gap separates segments, so a boundary shows without relying on color.
366  const gaps = Math.max(0, segments.length - 1);
367  const cells = allocateCells(
368    segments.map((segment) => segment.tokens),
369    Math.max(10, columns - 2 - gaps),
370  );
371  const share = (tokens: number) => {
372    const percent = (tokens / context.maxTokens) * 100;
373    return percent > 0 && percent < 1 ? "<1%" : `${Math.round(percent)}%`;
374  };
375
376  return (
377    <Box flexDirection="column" marginTop={1}>
378      <Box flexDirection="row" columnGap={1}>
379        <Text bold>Context window</Text>
380        <Text dimColor>{`${short(context.totalTokens)} of ${short(context.maxTokens)} tokens (${share(context.totalTokens)})`}</Text>
381      </Box>
382      <Box flexDirection="row">
383        {segments.map((segment, index) => (
384          <Text color={segment.color}>{(index > 0 ? " " : "") + segment.glyph.repeat(cells[index] ?? 0)}</Text>
385        ))}
386      </Box>
387      {/* Identity is never color alone: every color in the bar is named here, in the text color. */}
388      <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
389        {segments.map((segment) => (
390          <Box flexDirection="row" width={LEGEND_ENTRY_WIDTH} columnGap={1}>
391            <Text color={segment.color}>{segment.glyph.repeat(2)}</Text>
392            <Box width={20} flexShrink={1}>
393              <Text wrap="truncate-end">{segment.name}</Text>
394            </Box>
395            <Text dimColor>{`${short(segment.tokens)} · ${share(segment.tokens)}`}</Text>
396          </Box>
397        ))}
398      </Box>
399      {contextActions(ui, isWorking, contextAction, onContextAction)}
400    </Box>
401  );
402}
403
404/**
405 * Compact and Clear. Neither can run during a turn (compacting is refused,
406 * and a /clear would wait for the turn to end), so while Claude works the
407 * buttons give way to a note. Clear ends the conversation, so it asks first.
408 */
409function contextActions(
410  ui: Ui,
411  isWorking: boolean,
412  action: ChefStationContextAction | undefined,
413  onAction: TrayView["onContextAction"],
414) {
415  const { Box, Text, Button } = ui;
416  const message = action?.message ? <Text dimColor>{action.message}</Text> : null;
417
418  if (action?.isRunning) {
419    return (
420      <Box marginTop={1}>
421        <Text dimColor>Working on it…</Text>
422      </Box>
423    );
424  }
425
426  if (isWorking) {
427    return (
428      <Box flexDirection="column" marginTop={1}>
429        <Text dimColor>Clear and Compact are available once Claude finishes.</Text>
430        {message}
431      </Box>
432    );
433  }
434
435  if (action?.isConfirmingClear) {
436    return (
437      <Box flexDirection="column" marginTop={1}>
438        <Text>Clear the conversation? It ends here and a new one starts with an empty context.</Text>
439        <Box flexDirection="row" columnGap={1}>
440          <Button key="context-clear-confirm" label="Clear conversation" hotkey="y" variant="primary" onPress={() => onAction?.("confirm-clear")} />
441          <Button key="context-clear-cancel" label="Cancel" hotkey="n" onPress={() => onAction?.("cancel-clear")} />
442        </Box>
443      </Box>
444    );
445  }
446
447  return (
448    <Box flexDirection="column" marginTop={1}>
449      <Box flexDirection="row" columnGap={1}>
450        <Button key="context-compact" label="Compact" hotkey="c" onPress={() => onAction?.("compact")} />
451        <Button key="context-clear" label="Clear" hotkey="x" onPress={() => onAction?.("clear")} />
452      </Box>
453      {message}
454    </Box>
455  );
456}
457
hooks/transcript.mjs 222 lines
1// Reads Claude Code's session transcripts (~/.claude/projects/**/*.jsonl) and
2// tallies what each day cost. Plain JavaScript so the mod and the Node helper
3// in scripts/backfill.mjs share one implementation.
4
5/**
6 * @typedef {{ input: number, output: number, cacheRead: number, cacheWrite5m: number, cacheWrite1h: number }} EntryUsage
7 * @typedef {{ id: string, at: number, sessionId: string, project: string, model: string, isFast: boolean, usage: EntryUsage }} Entry
8 * @typedef {{ usd: number, tokens: number, cacheReadTokens: number, inputTokens: number, hours: number[], models: Record<string, number>, projects: Record<string, number> }} Day
9 * @typedef {{ days: Record<string, Day>, unpricedModels: string[], responses: number }} TallyResult
10 */
11
12/**
13 * US dollars per million tokens, first-party API list prices. Cache writes
14 * cost 1.25x input for the five-minute TTL and 2x input for the one-hour TTL.
15 * The first pattern that matches a model id wins, so specific ids come first.
16 */
17const PRICES = [
18  { pattern: /^claude-(fable|mythos)-5-1/, input: 10, output: 50, cacheRead: 0.25 },
19  { pattern: /^claude-(fable|mythos)-5/, input: 10, output: 50, cacheRead: 1 },
20  { pattern: /^claude-opus-5-5/, input: 4, output: 20, cacheRead: 0.2 },
21  { pattern: /^claude-opus-(5|4-8|4-7|4-6|4-5)/, input: 5, output: 25, cacheRead: 0.5 },
22  { pattern: /^claude-opus-4|^claude-3-opus/, input: 15, output: 75, cacheRead: 1.5 },
23  { pattern: /^claude-sonnet-5/, input: 2, output: 10, cacheRead: 0.2 },
24  { pattern: /^claude-sonnet-4|^claude-3-[57]-sonnet/, input: 3, output: 15, cacheRead: 0.3 },
25  { pattern: /^claude-haiku-4-5/, input: 1, output: 5, cacheRead: 0.1 },
26  { pattern: /^claude-3-5-haiku/, input: 0.8, output: 4, cacheRead: 0.08 },
27];
28
29/** Fast mode runs the same model at twice the price. */
30const FAST_MULTIPLIER = 2;
31
32/**
33 * One transcript line as a priced response, or undefined for every other line.
34 * Claude Code writes a response once per content block, so the same `id` can
35 * appear on several lines; the tally counts it once.
36 *
37 * @param {string} line
38 * @returns {Entry | undefined}
39 */
40export function readLine(line) {
41  // Most lines are prompts, tool results and content; skip them before parsing.
42  if (!line.includes('"usage"') || !line.includes('"assistant"')) {
43    return undefined;
44  }
45
46  let record;
47
48  try {
49    record = JSON.parse(line);
50  } catch {
51    return undefined;
52  }
53
54  const message = record?.message;
55  const usage = message?.usage;
56
57  if (record.type !== "assistant" || !usage || typeof message.model !== "string" || message.model === "<synthetic>") {
58    return undefined;
59  }
60
61  const at = Date.parse(record.timestamp);
62
63  if (Number.isNaN(at)) {
64    return undefined;
65  }
66
67  const cacheWrite = usage.cache_creation_input_tokens ?? 0;
68  const byTtl = usage.cache_creation;
69  const cacheWrite1h = byTtl?.ephemeral_1h_input_tokens ?? 0;
70  const cacheWrite5m = byTtl ? (byTtl.ephemeral_5m_input_tokens ?? 0) : cacheWrite;
71
72  return {
73    id: `${message.id}:${record.requestId}`,
74    at,
75    sessionId: String(record.sessionId ?? ""),
76    project: basename(String(record.cwd ?? "")) || "unknown",
77    model: message.model,
78    isFast: usage.speed === "fast",
79    usage: {
80      input: usage.input_tokens ?? 0,
81      output: usage.output_tokens ?? 0,
82      cacheRead: usage.cache_read_input_tokens ?? 0,
83      cacheWrite5m,
84      cacheWrite1h,
85    },
86  };
87}
88
89/**
90 * What a response cost in US dollars, or undefined when the model has no known price.
91 *
92 * @param {Entry} entry
93 * @returns {number | undefined}
94 */
95export function costOf(entry) {
96  const price = PRICES.find((candidate) => candidate.pattern.test(entry.model));
97
98  if (!price) {
99    return undefined;
100  }
101
102  const { input, output, cacheRead, cacheWrite5m, cacheWrite1h } = entry.usage;
103  const perMillion =
104    input * price.input +
105    output * price.output +
106    cacheRead * price.cacheRead +
107    cacheWrite5m * price.input * 1.25 +
108    cacheWrite1h * price.input * 2;
109
110  return (perMillion / 1_000_000) * (entry.isFast ? FAST_MULTIPLIER : 1);
111}
112
113/**
114 * Adds responses up by local calendar day, hour, model id and project.
115 *
116 * @param {{ since: number, excludeSessions?: Iterable<string> }} options
117 *   `since`: responses before this moment are left out; `excludeSessions`:
118 *   sessions the tray already recorded live, so they are not counted twice
119 */
120export function createTally({ since, excludeSessions = [] }) {
121  const excluded = new Set(excludeSessions);
122  const seen = new Set();
123  const unpriced = new Set();
124  /** @type {Record<string, Day>} */
125  const days = {};
126  let responses = 0;
127
128  return {
129    /** @param {Entry | undefined} entry */
130    add(entry) {
131      if (!entry || entry.at < since || excluded.has(entry.sessionId) || seen.has(entry.id)) {
132        return;
133      }
134
135      seen.add(entry.id);
136      responses += 1;
137
138      const usd = costOf(entry);
139
140      if (usd === undefined) {
141        unpriced.add(entry.model);
142      }
143
144      const cost = usd ?? 0;
145      const { input, output, cacheRead, cacheWrite5m, cacheWrite1h } = entry.usage;
146      const inputTokens = input + cacheRead + cacheWrite5m + cacheWrite1h;
147      const key = dayKey(entry.at);
148      const day = (days[key] ??= emptyDay());
149
150      day.usd += cost;
151      day.tokens += inputTokens + output;
152      day.cacheReadTokens += cacheRead;
153      day.inputTokens += inputTokens;
154      const hour = new Date(entry.at).getHours();
155      day.hours[hour] = (day.hours[hour] ?? 0) + cost;
156      day.models[entry.model] = (day.models[entry.model] ?? 0) + cost;
157      day.projects[entry.project] = (day.projects[entry.project] ?? 0) + cost;
158    },
159
160    /** @returns {TallyResult} */
161    result() {
162      return { days, unpricedModels: [...unpriced].sort(), responses };
163    },
164  };
165}
166
167/**
168 * Adds one transcript's responses to a tally, but only once the whole file
169 * has been read: a file that fails partway adds nothing, rather than part of
170 * its usage.
171 *
172 * @param {AsyncIterable<string> | Iterable<string>} lines
173 * @param {{ add: (entry: Entry | undefined) => void }} tally
174 * @returns {Promise<boolean>} whether the file was read to its end
175 */
176export async function addTranscript(lines, tally) {
177  /** @type {Entry[]} */
178  const staged = [];
179
180  try {
181    for await (const line of lines) {
182      const entry = readLine(line);
183
184      if (entry) {
185        staged.push(entry);
186      }
187    }
188  } catch {
189    return false;
190  }
191
192  for (const entry of staged) {
193    tally.add(entry);
194  }
195
196  return true;
197}
198
199/** @returns {Day} */
200function emptyDay() {
201  return { usd: 0, tokens: 0, cacheReadTokens: 0, inputTokens: 0, hours: new Array(24).fill(0), models: {}, projects: {} };
202}
203
204/**
205 * The local calendar date of a moment, as `YYYY-MM-DD`.
206 *
207 * @param {number} ms
208 */
209export function dayKey(ms) {
210  const date = new Date(ms);
211  const month = String(date.getMonth() + 1).padStart(2, "0");
212  const day = String(date.getDate()).padStart(2, "0");
213
214  return `${date.getFullYear()}-${month}-${day}`;
215}
216
217/** @param {string} path */
218function basename(path) {
219  const parts = path.split(/[\\/]/).filter(Boolean);
220  return parts[parts.length - 1] ?? "";
221}
222
hooks/context.ts 116 lines
1import type { ChefStationContextCategory } from "../types";
2
3/**
4 * Eight categorical colors, one per slot, in a fixed order. They are the
5 * dark-surface steps of the data-visualization reference palette, which pass
6 * every check on a light surface (#fcfcfb) and a dark surface (#1a1a19) alike:
7 * the lightness band, the chroma floor, protanopia and deuteranopia separation
8 * between neighbors (worst 8.4 against a target of 8), the normal-vision floor
9 * (worst 19.3 against 15) and contrast. The yellow sits at 2.99:1 on the light
10 * surface, which is why the legend always names each category beside its color.
11 *
12 * Only neighboring slots are validated as a pair: with all eight in play, some
13 * slots that are not neighbors (aqua and magenta under deuteranopia, red and
14 * orange for everyone) cannot be told apart. So the bar hands the slots out
15 * in its own order, and two segments that touch are always neighboring slots.
16 * The order is part of what makes neighbors distinguishable; never reorder it.
17 */
18export const CONTEXT_PALETTE = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"];
19
20/** A neutral gray for the categories folded into "Other": not a hue, so it claims no identity. */
21const OTHER_COLOR = "#8a8986";
22
23export type ContextSegment = {
24  name: string;
25  tokens: number;
26  kind: "used" | "free" | "buffer";
27  /** A palette color for a category, or a theme key for free space and the buffer. */
28  color: string;
29  /** The character the segment is drawn with, so free space and the buffer differ by texture as well. */
30  glyph: string;
31};
32
33/**
34 * The breakdown as the bar draws it, left to right: what fills the window, in
35 * the order /context lists it, then the free space, then the compaction
36 * buffer. Schemas loaded on demand sit outside the window and are left out.
37 * Each filled category takes the next palette slot in bar order; past the
38 * eighth, the rest fold into "Other" rather than reuse a color.
39 */
40export function contextSegments(categories: readonly ChefStationContextCategory[]): ContextSegment[] {
41  const used = categories.filter((category) => category.kind === "used" && category.tokens > 0);
42  const segments: ContextSegment[] = [];
43  let otherTokens = 0;
44
45  for (const [slot, category] of used.entries()) {
46    const color = CONTEXT_PALETTE[slot];
47
48    if (color === undefined) {
49      otherTokens += category.tokens;
50      continue;
51    }
52
53    segments.push({ name: category.name, tokens: category.tokens, kind: "used", color, glyph: "█" });
54  }
55
56  if (otherTokens > 0) {
57    segments.push({ name: "Other", tokens: otherTokens, kind: "used", color: OTHER_COLOR, glyph: "█" });
58  }
59
60  for (const category of categories) {
61    if (category.kind === "free" && category.tokens > 0) {
62      segments.push({ name: category.name, tokens: category.tokens, kind: "free", color: "subtle", glyph: "░" });
63    }
64  }
65
66  for (const category of categories) {
67    if (category.kind === "buffer" && category.tokens > 0) {
68      segments.push({ name: category.name, tokens: category.tokens, kind: "buffer", color: "subtle", glyph: "▒" });
69    }
70  }
71
72  return segments;
73}
74
75/**
76 * Shares `width` cells among `values` in proportion, adding up to exactly
77 * `width`. Any value above zero gets at least one cell, so every category the
78 * legend names is visible in the bar; those cells come from the largest share.
79 */
80export function allocateCells(values: readonly number[], width: number): number[] {
81  const total = values.reduce((sum, value) => sum + value, 0);
82
83  if (total <= 0 || width <= 0) {
84    return values.map(() => 0);
85  }
86
87  const exact = values.map((value) => (value / total) * width);
88  const cells = exact.map((share, index) => (values[index]! > 0 ? Math.max(1, Math.floor(share)) : 0));
89  let assigned = cells.reduce((sum, count) => sum + count, 0);
90
91  // Hand the cells rounding left over to the largest fractional parts, earliest first on a tie.
92  const byRemainder = exact
93    .map((share, index) => ({ index, remainder: share - Math.floor(share) }))
94    .filter(({ index }) => values[index]! > 0)
95    .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
96
97  for (let next = 0; assigned < width && byRemainder.length > 0; next = (next + 1) % byRemainder.length) {
98    cells[byRemainder[next]!.index]! += 1;
99    assigned += 1;
100  }
101
102  // The one-cell minimums can overshoot; take the excess from the largest share.
103  while (assigned > width) {
104    const largest = cells.indexOf(Math.max(...cells));
105
106    if (cells[largest]! <= 1) {
107      break;
108    }
109
110    cells[largest]! -= 1;
111    assigned -= 1;
112  }
113
114  return cells;
115}
116
types/index.d.ts 146 lines
1/**
2 * The four stations of the tray, one per card of the overview.
3 */
4export type ChefStationName = "usage" | "trend" | "breakdown" | "activity" | "context";
5
6/** One row of the context window's breakdown, as /context lists it. */
7export type ChefStationContextCategory = {
8  name: string;
9  tokens: number;
10  /** `used` fills the window, `free` is what is left, `buffer` the compaction reserve, `deferred` schemas outside the window. */
11  kind: "used" | "free" | "buffer" | "deferred";
12};
13
14export type ChefStationContextAction = {
15  isRunning: boolean;
16  isConfirmingClear: boolean;
17  message?: string;
18};
19
20/** The context window broken down by category, as the last measurement found it. */
21export type ChefStationContext = {
22  categories: ChefStationContextCategory[];
23  /** Tokens in use. */
24  totalTokens: number;
25  /** The window measured against: the model's limit, or a smaller compaction window. */
26  maxTokens: number;
27};
28
29/**
30 * What one calendar day (local time) has cost, across every session.
31 */
32export type ChefStationDay = {
33  /** US dollars, as `/cost` totals them. */
34  usd: number;
35  /** Every token the day's turns counted: input, output and both cache kinds. */
36  tokens: number;
37  /** Input tokens served from the prompt cache. */
38  cacheReadTokens: number;
39  /** Every input token: uncached, cache reads and cache writes. */
40  inputTokens: number;
41  /** US dollars per hour of the day, index 0 being midnight to 1am. */
42  hours: number[];
43  /** US dollars per model display name. */
44  models: Record<string, number>;
45  /** US dollars per project (the folder the session runs in). */
46  projects: Record<string, number>;
47};
48
49/**
50 * Every day the tray remembers, keyed `YYYY-MM-DD`; kept in `$.store` so it
51 * outlives the session, and pruned to the last thirteen weeks.
52 */
53export type ChefStationLedger = {
54  version: 1;
55  /** What the tray recorded live, turn by turn. */
56  days: Record<string, ChefStationDay>;
57  /** Sessions the tray recorded live, with the last day it saw each, so a backfill skips them. */
58  liveSessions?: Record<string, string>;
59  /** What the last scan of Claude Code's transcripts found, for every other session. */
60  backfill?: ChefStationBackfill;
61};
62
63/**
64 * The days a scan of `~/.claude/projects` tallied. Models are keyed by
65 * display name and costs are estimated from list prices, as the transcripts
66 * hold token counts and no cost.
67 */
68export type ChefStationBackfill = {
69  days: Record<string, ChefStationDay>;
70  /** When the scan finished, in milliseconds since the epoch. */
71  scannedAt: number;
72  /** `node` when the helper script ran, `fs` when the mod read the files itself. */
73  source: "node" | "fs";
74  files: number;
75  /** Transcripts too large for the mod to read itself (only when Node was not found). */
76  skippedFiles: number;
77  /** Transcripts whose read failed; the backfill is incomplete when there are any. Absent before 0.3.1. */
78  unreadableFiles?: number;
79  /** Model ids with no known price; their tokens count but their cost does not. */
80  unpricedModels: string[];
81};
82
83/**
84 * What this session alone is doing right now: the "Now" row.
85 */
86export type ChefStationLive = {
87  project: string;
88  model: string;
89  /** Output tokens this session's turns generated. */
90  outputTokens: number;
91  /** This session's cost so far, in US dollars. */
92  usd: number;
93  /** When the session began, in milliseconds since the epoch. */
94  startedAt: number;
95  /** When the running turn began; absent while the session is idle. */
96  turnStartedAt?: number;
97  /** How long the last completed turn took, in milliseconds. */
98  lastTurnMs?: number;
99  /** The context window's fill, 0 to 100, when a response reported one. */
100  contextPercent?: number;
101  /** The account's rate-limit windows, as the last response reported them. */
102  rateLimits: ChefStationRateLimit[];
103};
104
105export type ChefStationRateLimit = {
106  kind: string;
107  percentUsed: number;
108  resetsAt?: string;
109};
110
111/** How long a prompt-cache entry lives after the request that last read or wrote it. */
112export type ChefStationCacheTtl = "5m" | "1h";
113
114/** What the tray knows about the main conversation's prompt cache. */
115export type ChefStationCache = {
116  /** When the main conversation's last model request started, in milliseconds since the epoch. */
117  lastRequestAt?: number;
118  /** The lifetime of the latest cache write, as the transcript records it; unknown until a turn ends. */
119  ttl?: ChefStationCacheTtl;
120};
121
122declare module "claude-code" {
123  interface PluginState {
124    "chef-station": {
125      /** The station the tray shows. */
126      station: ChefStationName;
127      /** The persisted ledger, mirrored here so the band redraws when it moves. */
128      ledger: ChefStationLedger;
129      /** This session's live figures. */
130      live: ChefStationLive | null;
131      /** The time the tray last ticked, so countdowns move without a turn. */
132      now: number;
133      /** The session cost already written to the ledger, so no turn counts twice. */
134      recordedUsd: number;
135      /** The main conversation's prompt cache: when it was last used, and how long its entries live. */
136      cache: ChefStationCache;
137      /** The context window by category, refreshed after each turn; null before the first measurement. */
138      context: ChefStationContext | null;
139      /** The Context station's Clear and Compact: whether one is running, whether Clear awaits confirmation, and what the last one said. */
140      contextAction: ChefStationContextAction;
141      /** Whether a scan of the transcripts is running, and what the last one said. */
142      backfillStatus: { isRunning: boolean; message?: string };
143    };
144  }
145}
146