SLOPSHOPPER

token-weather

A live forecast of the context window above the prompt, with the 5-hour and 7-day usage windows, the session cost, today's spend and the subagent tally on the…

newpanebandspinnercommandtoast
★ 1v0.6.0Apache-2.0updated 2026-10-09Nasrallah-Adel/claude-token-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather
│ ┃ weather ✕ › fix the failing auth test and add an audit log call │ ┃ ☁ Cloudy 49% 97.4k / 200k │ ┃ turns 2 peak 97.4k ⏺ Read(src/auth.ts) │ ┃ ██ ⎿ Read 6 lines │ ┃ 5h ██████░░░░░░░░░░░░░░ 31% ⏺ Update(src/auth.ts) │ ┃ session $0.42 today $0.42 week $0.42 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ close ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /weather │ ⎿ token-weather: ☁ Cloudy 49% of context 97.4k / 200k claude-op │ ⎿ token-weather: │ ⎿ token-weather: last turns (2) │ ⎿ token-weather: 97.4k │ ⎿ token-weather: 97.4k steady │ ⎿ token-weather: │ │ ☁ Cloudy 49% of context 97.4k / 200k last turns ██ steady · 5h 31% · $0.42 [ compact ] [ context ] [ cost ] [ clear ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% of context 97.4k / 200k last turns ██ steady · 5h 31% · $0.42 [ compact ] [ context ] [ cost ] [ clear ] ⟨Claude Code's own drawing⟩
Pane · weather
☁ Cloudy 49% 97.4k / 200k turns 2 peak 97.4k ██ 5h ██████░░░░░░░░░░░░░░ 31% session $0.42 today $0.42 week $0.42 [ close ]
README

token-weather

A Claude Code mod: a one-line forecast of the context window above the prompt (or under it, by option), with the account's usage windows, the session cost, today's spend and the subagent tally on the same line, buttons that act on it, a /weather command, a pane, and a set of optional actions: compact at a threshold, hold a prompt when a window or a cost cap is spent, offer a cheaper model, notify, beep.

☀ Clear  23% of context  234.3k / 1M   last turns ▇█  ▲ +281  · 5h 28% ↻43m · 7d 35% ↻1d5h · $12.55 · today $18.40 · wk $31.00 · agents 1.2M  [ compact ] [ context ] [ cost ] [ clear ]

Every action is off until you turn it on in /config; the hints (the /compact suggestion, the buttons, today's spend, the agents tally) are on.

The line

  • Weather: ☀ Clear under 25% of the window, ☁ Cloudy under 50%, ☂ Showers under 75%, ☇ Storm under 90%, ↯ Compact soon above.
  • Token count (234.3k): bold, coloured by size: green under warnTokens, yellow from there, red from dangerTokens. The window (/ 1M) stays dim.
  • ⚠2x: shown after the count from warnTokens up: the prompt is above the long-context line, where 1M-context models bill input at a higher rate.
  • Live count: the count moves with every model request of a turn, after every command (/context, /compact, /clear change the context without a turn) and on every measure, so it reads as the status line does. The chart, the trend and the crossings still take one reading per completed turn.
  • Chart: one bar per recent turn (the last 12), scaled to the busiest, and the change since the last turn. Hidden under 60 columns.
  • Fit: a band narrower than the line drops parts in order, agents, spend, trend, chart, then the windows and cost, then the window and tag. The buttons sit at the end of the line when they fit beside the windows and the cost, else on a row of their own under it, so a docked pane never pushes the figures off the row.
  • 5h / 7d: the 5-hour and weekly rate-limit windows, percent used and ↻ time until reset. Green below 50% used, yellow below 80%, red from there.
  • $: what the session has cost so far, as /cost totals it.
  • today / wk (showDailyCost): spend summed over every session in the plugin's store, today and this ISO week. Each session writes only its own row, so sessions running side by side add up without overwriting each other; a session's spend counts on the day it started; rows older than eight weeks are dropped at the next start.
  • agents (showAgents): the tokens the session's subagents, forks and teammates have used, from their turn.complete usage. Their cost is already inside $.
  • Toasts: once per conversation when the context passes warnTokens, once more at dangerTokens, and once per window when the 5h or 7d window reaches rateWarnPercent. Re-armed by /clear, /weather reset, and whenever the figure falls back under the line.

Buttons

With buttons on and the band at least 51 columns wide, four buttons end the line (or take the row under it when the line is full): [ compact ], [ context ], [ cost ], [ clear ]. Click one, or press ctrl+x tab to focus the band and then its letter: c, x, d, n. compact asks first when compactConfirm is on and passes compactFocus to the summarizer; clear always asks; context and cost run the slash commands. The buttons hide while a turn runs (commands wait for an idle session) and never draw on the hint row (placement: below).

/weather

/weather           the forecast, the last turns, the windows, cost, spend, agents, the context
                   categories as /context counts them, and every threshold in force
/weather reset     forget the readings and re-arm the toasts and actions
/weather pane      open or close the pane
/weather compact   compact now, with the compactFocus instructions, no question asked
/weather ledger    spend by day over the stored sessions, and this week's total

The command's output is a transcript row like any typed command's; nothing of it is sent to the model on its own.

The pane

/weather pane opens Token weather beside the transcript: the forecast, a chart of up to 200 readings bucketed to the pane's width, each usage window as a bar with its reset countdown, the cost lines and the agents tally, and a close button. pane: true opens it at session start, which the terminal only places from 144 columns; a narrower terminal gets a toast instead. Opened by the command it is placed at any width.

Actions

Each one is an option in /config under token-weather, or in pluginConfigs in ~/.claude/settings.json (the id is token-weather@skills-dir for a skills-dir install, or token-weather@nasrallah-mods from the marketplace).

Compact at a threshold (compactAt, tokens, 0 = off). After the turn whose reading crossed the line, the mod asks Context at 352k. Compact now? (compactConfirm, default on) and runs the same compaction /compact does, with compactFocus as the instructions. Later leaves the line crossed; it fires again once the context has dropped below the line and climbs back over it. A subagent's turn never triggers it. Compaction cannot run while a turn is in flight, so every action runs just after the hook that saw the reading returns; should the direct call still be refused, the mod queues /compact for the idle session.

Compaction focus (compactFocus, text). Besides the compactions this mod triggers, the text is appended to the instructions of every /compact you type and every auto-compaction of the main conversation. This is the one option whose text the model reads: the summarizer sees it.

Suggest /compact (suggestCompact, default on). From dangerTokens up, after each turn the prompt box shows /compact as its dim suggestion; Tab takes it, Enter sends it. Nothing is sent unless you do.

Hold prompts on the 5-hour window (guardRateLimit, percent, 0 = off). When the 5h window is at or past the line, each prompt you send first asks 5h window at 96%. Send anyway?. Later drops the prompt with the reason shown and puts your text back in the prompt box. Prompts a plugin, a schedule or a peer submits are never held. A failing guard passes the prompt, never drops it.

Session cost cap (costLimitUsd, dollars, 0 = off). A toast at 80% of the cap, and at the cap each prompt asks Session cost $12.00 is past the $10.00 cap. Send anyway? the same way.

Model downshift (downshiftModel, an alias such as sonnet, empty = off; downshiftAt, percent, default 90). Once per session, when the weekly window (or the 5-hour one, where there is no weekly window) reaches the line, the mod asks 7d window at 91%. Switch to sonnet?; Switch runs /model sonnet. The alias is passed as typed; /model reports an unknown one.

Notifications (notify, default off). The warn and danger crossings, a window reaching rateWarnPercent and the cost cap's 80% mark also raise a native notification through your preferredNotifChannel, headed token-weather.

Sound (sound: off, beep, speak). On the same crossings, beep plays a short tone (sounds/warn.wav, sounds/danger.wav, through afplay; a Linux or Windows terminal plays nothing) and speak reads the alert with the system voice.

Options

keytypedefaulteffect
placementabove / belowabovethe band above the prompt, stacked with other mods' bands and with the buttons; or the hint row under it
warnTokensnumber200000yellow count, ⚠2x tag, first toast
dangerTokensnumber300000red count, second toast, /compact suggestion from here
suggestCompactbooleantruedim /compact suggestion after each turn in the red zone
buttonsbooleantruecompact, context, cost, clear buttons beside or under the line, from 51 columns
showDailyCostbooleantruetoday $X · wk $Y on the line
showAgentsbooleantrueagents 1.2M on the line
compactAtnumber0tokens; compact after the turn that crossed it; 0 off
compactConfirmbooleantrueask Compact / Later first (compactAt and the button)
compactFocusstring""instructions for every compaction, this mod's and yours
notifybooleanfalsenative notification on alerts
rateWarnPercentnumber90window percent that raises an alert; 0 off
soundoff / beep / speakofftone or speech on alerts
guardRateLimitnumber05h window percent from which prompts ask Send / Later; 0 off
costLimitUsdnumber0session cost cap: toast at 80%, prompts ask at 100%; 0 off
downshiftModelstring""alias offered once when the window runs low; empty off
downshiftAtnumber907d (else 5h) window percent for the offer
panebooleanfalseopen the pane at session start

Numbers that are not finite or are negative fall back to their default; dangerTokens is never below warnTokens; percents are 0 (off) or 1 to 100.

What reaches the model

The band, the toasts, the notifications, the sounds, the guards' questions and /weather's output are drawn for you alone. Three things do reach the model, each by your hand or your setting: compactFocus, read by the summarizer when a compaction runs; a /compact suggestion you take with Tab and send; and /model, /compact, /context, /cost or /clear run from a button, an offer or /weather, whose rows enter the transcript as a typed command's would. The figures come from $.session.usage(), the same ones the status line reads; no model call is made for them.

Install

In a Claude Code terminal session (2.1.287 or later, where mods load by default):

/plugin install token-weather --marketplace Nasrallah-Adel/claude-token-weather

Answer y to add the marketplace, then pick the user scope (Enter) so it loads in every project. It is active at once in that session and in each session started after.

To try it from a clone instead, for one session with hot reload:

git clone https://github.com/Nasrallah-Adel/claude-token-weather.git
claude --plugin-dir ./claude-token-weather

Notes

  • With placement: "above", the band above the prompt is one slot shared by every mod. This mod awaits the mods beneath it and stacks its line above theirs, so it coexists with other band-drawing mods such as prompt-cache-control. A mod that draws without passing the band on will hide it.
  • Windows are empty until the first API response of a session, and off a subscription (API key, cloud provider): then only the cost shows, and the window-based actions never fire.
  • The cost is uncolored, since there is no natural dollar threshold.
  • A /config change reloads the module: the readings start over and the toasts are armed again, as after /clear. The spend ledger lives in the plugin's store and survives.

Develop

claude plugin validate .   # what it hooks and calls
claude plugin test .       # the tests in tests/: pure helpers and the module through the engine's kit

hooks/token-weather.mjs is the one module that touches $ (the host follows $ only inside the file it is handed to); the rest are pure and tested on their own: options, readings, band, guards, spend, weather-command, pane, usage-status.

Hooks: session.start, session.end, session.measure, session.compact, turn.step, turn.complete, prompt.submit, command.run (/weather, and every command for the live count), ui.render on AbovePrompt, PromptHint and the weather pane.

License

Apache-2.0. The context-window forecast started from the token-weather example in anthropics/claude-code-playground; the usage windows, cost, colors, band stacking, actions, command and pane are this repo's. NOTICE carries the attribution Apache-2.0 requires.

Source 9 files
hooks/token-weather.mjs 472 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Token Weather: a live forecast of the context window above the prompt, and
5// the actions it can take from there.
6//
7// This is the one module that touches $: the host follows $ only inside the
8// file it is handed to, so every on(...) and $.noun.method(...) is spelled
9// here, and helpers that take $ are top-level functions. The pure parts live
10// beside it: options, readings, band, guards, spend, weather-command, pane.
11//
12// Hooks: session.start, session.end, session.measure, session.compact,
13// turn.step, turn.complete, prompt.submit, command.run, ui.render on
14// PromptHint, AbovePrompt and the weather Pane.
15//
16// Two readings: `live` follows every model request, every command and every
17// measure, so the count on the band is the status line's; `readings` takes
18// one entry per completed turn, for the chart, the trend and the crossings.
19
20import { parseOptions } from "./options.mjs";
21import { pushReading, readingFrom, crossings, short, addAgentUsage, EMPTY_TALLY } from "./readings.mjs";
22import { bandParts, buttonSpecs, fitParts, layoutBand } from "./band.mjs";
23import { guardVerdict, rateCrossings, downshiftDue, costWarningDue, windowLabel } from "./guards.mjs";
24import { spendKey, spendEntry, sumSpend, staleKeys, SPEND_PREFIX, usd } from "./spend.mjs";
25import { parseWeatherArgs, weatherReport, ledgerReport, HELP } from "./weather-command.mjs";
26import { paneRows } from "./pane.mjs";
27
28const USAGE_TICK_MS = 60_000;
29// Actions wait for the hook that saw the reading to return: a turn's hooks are waited on.
30const ACT_DELAY_MS = 150;
31const PANE_ID = "weather";
32const PANE_COLUMNS = 60;
33const SOUND_ASSET = { warn: "sounds/warn.wav", danger: "sounds/danger.wav" };
34
35// The options, parsed once per load.
36let opts = parseOptions(undefined);
37// Everything the drawings read; replaced, never mutated.
38let state = fresh();
39let usageTick;
40let sessionId;
41// This conversation's own spend row, the session cost it last saw, and what the other rows add up to.
42let ownSpend;
43let lastSeenUsd = 0;
44let othersSpend = { today: 0, week: 0 };
45
46function fresh() {
47  return {
48    readings: [],
49    live: undefined,
50    usage: undefined,
51    spend: { today: 0, week: 0 },
52    agents: EMPTY_TALLY,
53    lastTokens: 0,
54    rateFired: {},
55    cost80: false,
56    downshifted: false,
57    compactFailed: false,
58  };
59}
60
61function set(patch) {
62  state = { ...state, ...patch };
63}
64
65export function register(on, options) {
66  opts = parseOptions(options);
67
68  on("session.start", async ($, e, next) => {
69    const result = await next(e);
70    // This mod pins nothing under the prompt; clear an entry an earlier build left.
71    $.ui.status(undefined);
72    state = fresh();
73    await registerCommand($);
74    await takeReading($);
75    await loadSpend($);
76    if (usageTick) usageTick.cancel();
77    usageTick = $.clock.every(USAGE_TICK_MS, () => refreshSpend($));
78    if (opts.pane) await openPane($, false);
79    return result;
80  });
81
82  // /clear starts a new conversation in the same process: fresh readings, every line armed again.
83  on("session.end", ($, e, next) => {
84    if (e.reason === "clear") {
85      state = { ...fresh(), usage: state.usage, spend: state.spend };
86      // The new conversation has an id of its own: its spend starts a row of its own.
87      sessionId = undefined;
88      ownSpend = undefined;
89      $.ui.invalidate("ui.render");
90    }
91    return next(e);
92  });
93
94  on("session.measure", async ($, e, next) => {
95    set({ usage: { rateLimits: e.rateLimits, cost: e.cost }, live: readingFrom(e.context) ?? state.live });
96    if (e.changed.includes("cost") && e.cost) await recordSpend($, e.cost.usd);
97    onWindowsMoved($, e.rateLimits);
98    onCostMoved($, e.cost?.usd);
99    $.ui.invalidate("ui.render");
100    return next(e);
101  });
102
103  // The focus text rides along with /compact and auto-compaction too, when set.
104  on("session.compact", ($, e, next) => {
105    if (!opts.compactFocus || e.agentId || !Array.isArray(e.messages) || (e.trigger !== "manual" && e.trigger !== "auto")) return next(e);
106    const instructions = [e.instructions, opts.compactFocus].filter(Boolean).join("\n");
107    return next({ ...e, instructions });
108  }).catch(($, e, next) => next(e));
109
110  // Each model request moves the live count: the band follows the status line mid-turn.
111  on("turn.step", async function* ($, e, next) {
112    const result = yield* next(e);
113    if (!e.agentId) await liveReading($);
114    return result;
115  });
116
117  on("turn.complete", async ($, e, next) => {
118    const result = await next(e);
119    if (e.agentId) {
120      set({ agents: addAgentUsage(state.agents, e.agentId, e.usage) });
121      $.ui.invalidate("ui.render");
122      return result;
123    }
124    const previous = state.lastTokens;
125    await takeReading($);
126    if (e.reason === "answer") afterTurn($, previous, state.lastTokens);
127    return result;
128  });
129
130  // The guards: a prompt waits for a yes when a window or the cost cap says so.
131  // A failing guard passes the prompt, never drops it: the .catch answers next(e).
132  on("prompt.submit", async ($, e, next) => {
133    const verdict = guardVerdict(state.usage, opts, e.origin?.kind);
134    if (!verdict) return next(e);
135    let choice;
136    try {
137      choice = await $.ui.ask(verdict.question, ["Send", "Later"]);
138    } catch {
139      choice = "Later";
140    }
141    if (choice === "Send") return next(e);
142    // The text goes back in the box, so Later costs nothing but the send.
143    defer($, () => $.prompt.fill({ text: e.text }));
144    return { drop: `token-weather: ${verdict.reason}` };
145  }).catch(($, e, next) => next(e));
146
147  on("command.run", { command: "weather" }, async ($, e) => ({ text: await runWeather($, e) }))
148    .catch(($, e, next) => (next.called ? next(e) : { text: `token weather: ${message(next.error?.cause ?? next.error)}` }));
149
150  // A command's rows (/context, /compact, /clear) change the context without a turn: read it again after.
151  on("command.run", async ($, e, next) => {
152    const result = await next(typeof e.args === "string" ? e : { ...e, args: "" });
153    if (e.command !== "weather") defer($, () => liveReading($));
154    return result;
155  }).catch(($, e, next) => next(e));
156
157  // Below the prompt: the dim hint row. The engine's own hint ("auto mode on…") follows the line, dim.
158  on("ui.render", { component: "PromptHint" }, ($, e, next) => {
159    if (opts.placement !== "below" || state.readings.length === 0) return next(e);
160    const { Box, Text } = $.ui.resolve(e);
161    const columns = e.viewport?.columns ?? e.props?.bodyColumns ?? 80;
162    // Two cells of padding, and the engine's hint keeps its own room at the end.
163    const reserve = 2 + (e.props?.hint ? [...e.props.hint].length + 4 : 0);
164    const line = fitParts(bandParts(state, opts, columns, Date.now()), columns, reserve).map((p) => Text(textProps(p)));
165    if (e.props?.hint) line.push(Text({ dimColor: true, wrap: "truncate-end", children: `  · ${e.props.hint}` }));
166    return Box({ flexDirection: "row", paddingX: 1, children: line });
167  });
168
169  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
170    if (opts.placement !== "above" || e.props?.hasSurvey || state.readings.length === 0) return next(e);
171    const own = drawBand($, e);
172    // The band is one instance: keep whatever the mods beneath draw, under this line.
173    const below = await next(e);
174    const { Box } = $.ui.resolve(e);
175    return below ? Box({ flexDirection: "column", children: [own, below] }) : own;
176  });
177
178  on("ui.render", { component: "Pane", requestId: PANE_ID }, ($, e) => drawPane($, e));
179}
180
181// ---- readings and what follows a turn ----------------------------------------------------
182
183async function takeReading($) {
184  try {
185    const { context, rateLimits, cost } = await $.session.usage();
186    const reading = readingFrom(context);
187    if (!reading) {
188      set({ usage: { rateLimits, cost } });
189      return;
190    }
191    set({
192      usage: { rateLimits, cost },
193      readings: pushReading(state.readings, reading),
194      live: reading,
195      lastTokens: reading.tokens,
196    });
197    $.ui.invalidate("ui.render");
198  } catch {
199    // No reading this turn; the band keeps the last one.
200  }
201}
202
203// The count as it stands now, without a bar on the chart.
204async function liveReading($) {
205  try {
206    const { context, rateLimits, cost } = await $.session.usage();
207    const reading = readingFrom(context);
208    if (!reading) return;
209    set({ usage: { rateLimits, cost }, live: reading });
210    $.ui.invalidate("ui.render");
211  } catch {
212    // The band keeps the last reading.
213  }
214}
215
216function afterTurn($, previous, tokens) {
217  const crossed = crossings(previous, tokens, { warn: opts.warnTokens, danger: opts.dangerTokens, compact: opts.compactAt });
218  if (crossed.includes("danger")) {
219    alert($, `context passed ${short(opts.dangerTokens)}: red zone, consider /compact`, "danger");
220  } else if (crossed.includes("warn")) {
221    alert($, `context passed ${short(opts.warnTokens)}: long-context rate from here`, "warn");
222  }
223  if (crossed.includes("compact")) {
224    defer($, () => runCompact($, { confirm: opts.compactConfirm, tokens }));
225  } else if (opts.suggestCompact && tokens >= opts.dangerTokens) {
226    defer($, () => $.prompt.suggest({ text: "/compact" }));
227  }
228}
229
230function onWindowsMoved($, rateLimits) {
231  const { fired, state: rateFired } = rateCrossings(state.rateFired, rateLimits, opts.rateWarnPercent);
232  set({ rateFired });
233  for (const kind of fired) {
234    const w = rateLimits.find((x) => x.kind === kind);
235    alert($, `${windowLabel(kind)} window at ${Math.round(w.percentUsed)}%`, "warn");
236  }
237  const due = downshiftDue(rateLimits, opts);
238  if (due && !state.downshifted) {
239    set({ downshifted: true });
240    defer($, () => runDownshift($, due));
241  }
242}
243
244function onCostMoved($, cost) {
245  if (!state.cost80 && costWarningDue(cost, opts)) {
246    set({ cost80: true });
247    alert($, `session cost ${usd(cost)}: 80% of the ${usd(opts.costLimitUsd)} cap`, "warn");
248  }
249}
250
251// ---- the actions ----------------------------------------------------------------------------
252
253// Runs `fn` once the hook that scheduled it has returned: a turn's hooks are waited on, and
254// compaction, commands, questions and suggestions all refuse to run inside one.
255function defer($, fn) {
256  $.clock.after(ACT_DELAY_MS, async () => {
257    try {
258      await fn();
259    } catch (error) {
260      $.ui.toast(`token-weather: ${message(error)}`);
261    }
262  });
263}
264
265function alert($, text, kind) {
266  $.ui.toast(text);
267  if (opts.notify) {
268    $.ui.notify(text, { title: "token-weather" }).catch(() => {});
269  }
270  if (opts.sound === "beep") {
271    $.audio.play({ asset: SOUND_ASSET[kind] ?? SOUND_ASSET.warn }).catch(() => {});
272  } else if (opts.sound === "speak") {
273    $.audio.speak(text).catch(() => {});
274  }
275}
276
277async function runCompact($, { confirm, tokens }) {
278  if (confirm) {
279    let choice;
280    try {
281      choice = await $.ui.ask(`Context at ${short(tokens ?? state.lastTokens)}. Compact now?`, ["Compact", "Later"]);
282    } catch {
283      choice = "Later";
284    }
285    if (choice !== "Compact") return "later";
286  }
287  const args = opts.compactFocus ? { instructions: opts.compactFocus } : undefined;
288  try {
289    const result = await $.session.compact(args);
290    if (result && "skip" in result) {
291      $.ui.toast(`compaction skipped: ${result.skip}`);
292      return "skipped";
293    }
294    return "compacted";
295  } catch (error) {
296    // Between turns the direct call works; mid-turn the command queues until the session is idle.
297    await $.command.run({ command: "compact", args: opts.compactFocus });
298    return `queued (${message(error)})`;
299  }
300}
301
302async function runDownshift($, due) {
303  let choice;
304  try {
305    choice = await $.ui.ask(`${due.window} window at ${Math.round(due.percent)}%. Switch to ${opts.downshiftModel}?`, ["Switch", "Keep"]);
306  } catch {
307    choice = "Keep";
308  }
309  if (choice !== "Switch") return;
310  await $.command.run({ command: "model", args: opts.downshiftModel });
311}
312
313async function pressButton($, key) {
314  if (key === "compact") return runCompact($, { confirm: opts.compactConfirm });
315  if (key === "clear") {
316    let choice;
317    try {
318      choice = await $.ui.ask("Clear the conversation?", ["Clear", "Keep"]);
319    } catch {
320      choice = "Keep";
321    }
322    if (choice !== "Clear") return;
323  }
324  await $.command.run({ command: key });
325}
326
327// ---- spend ledger over $.store ----------------------------------------------------------------
328
329async function loadSpend($) {
330  try {
331    sessionId = await $.session.id();
332    const keys = (await $.store.keys()).filter((k) => k.startsWith(SPEND_PREFIX));
333    const rows = Object.fromEntries(await Promise.all(keys.map(async (k) => [k, await $.store.get(k)])));
334    const now = Date.now();
335    for (const key of staleKeys(rows, now)) await $.store.delete(key);
336    ownSpend = rows[spendKey(sessionId)];
337    othersSpend = sumSpend(Object.entries(rows).filter(([k]) => k !== spendKey(sessionId)).map(([, row]) => row), now);
338    const own = sumSpend([ownSpend], now);
339    set({ spend: { today: othersSpend.today + own.today, week: othersSpend.week + own.week } });
340  } catch {
341    // No ledger: the band shows the session cost alone.
342  }
343}
344
345// Other sessions write their own rows: read them again on the clock, so the total keeps up.
346async function refreshSpend($) {
347  await loadSpend($);
348  $.ui.invalidate("ui.render");
349}
350
351// The session cost is a running total; the row takes what it grew by since the last
352// measure, so a /clear (a new id, a cost that may start over) never loses or doubles a cent.
353async function recordSpend($, cost) {
354  if (typeof cost !== "number" || !Number.isFinite(cost)) return;
355  try {
356    if (!sessionId) {
357      sessionId = await $.session.id();
358      const existing = await $.store.get(spendKey(sessionId));
359      ownSpend = existing && typeof existing.usd === "number" ? existing : undefined;
360    }
361    const delta = cost < lastSeenUsd ? cost : cost - lastSeenUsd;
362    lastSeenUsd = cost;
363    const now = Date.now();
364    // The row keeps the day the conversation started counting; only the amount grows.
365    ownSpend = ownSpend ? { ...ownSpend, usd: ownSpend.usd + delta, at: now } : spendEntry(delta, now);
366    await $.store.set(spendKey(sessionId), ownSpend);
367    const own = sumSpend([ownSpend], now);
368    set({ spend: { today: othersSpend.today + own.today, week: othersSpend.week + own.week } });
369  } catch {
370    // The ledger is a convenience; a failed write costs nothing else.
371  }
372}
373
374// ---- /weather ---------------------------------------------------------------------------------
375
376async function registerCommand($) {
377  try {
378    await $.command.register({
379      name: "weather",
380      description: "Token weather: the forecast, usage windows, spend and agents; reset | pane | compact | ledger",
381      argumentHint: "[reset|pane|compact|ledger]",
382    });
383  } catch {
384    // A refused registration leaves the band working.
385  }
386}
387
388async function runWeather($, e) {
389  const form = parseWeatherArgs(e.args);
390  if (form === "help") return HELP;
391  if (form === "reset") {
392    state = { ...fresh(), usage: state.usage, spend: state.spend };
393    $.ui.invalidate("ui.render");
394    return "token weather: readings forgotten, toasts and actions re-armed";
395  }
396  if (form === "pane") return togglePane($);
397  if (form === "compact") return `token weather: ${await runCompact($, { confirm: false })}`;
398  if (form === "ledger") return ledgerReport(await spendRows($), Date.now());
399  return weatherReport(await reportView($, e.presentation?.columns), opts, Date.now());
400}
401
402async function reportView($, columns) {
403  const view = { readings: state.readings, live: state.live, usage: state.usage, spend: state.spend, agents: state.agents };
404  try {
405    const usage = await $.session.usage({ breakdown: "summary", columns });
406    return { ...view, categories: usage.context?.breakdown?.categories, model: usage.context?.breakdown?.model ?? (await $.session.model()) };
407  } catch {
408    return view;
409  }
410}
411
412async function spendRows($) {
413  const keys = (await $.store.keys()).filter((k) => k.startsWith(SPEND_PREFIX));
414  return Promise.all(keys.map((k) => $.store.get(k)));
415}
416
417// ---- the pane ---------------------------------------------------------------------------------
418
419async function openPane($, asked) {
420  const opened = await $.ui.open({ id: PANE_ID, title: "Token weather", columns: PANE_COLUMNS });
421  if (!opened.isPlaced && asked) return `token weather: the pane is not placed (${opened.reason})`;
422  if (!opened.isPlaced) $.ui.toast(`token-weather: pane waits for a wider terminal (${opened.reason})`);
423  return opened.isPlaced ? "token weather: pane opened" : "";
424}
425
426async function togglePane($) {
427  const open = (await $.ui.panes()).some((p) => p.id === PANE_ID);
428  if (open) {
429    await $.ui.close({ id: PANE_ID });
430    return "token weather: pane closed";
431  }
432  return openPane($, true);
433}
434
435// ---- drawing ----------------------------------------------------------------------------------
436
437function textProps(part) {
438  return { color: part.color, bold: part.bold, dimColor: part.dim, children: part.text };
439}
440
441function drawBand($, e) {
442  const { Box, Text, Button } = $.ui.resolve(e);
443  const columns = e.props?.bodyColumns ?? e.viewport?.columns ?? 80;
444  const specs = buttonSpecs(opts, columns, Boolean(e.props?.isWorking));
445  const laid = layoutBand(bandParts(state, opts, columns, Date.now()), columns, specs);
446  const line = laid.parts.map((p) => Text(textProps(p)));
447  const buttons = laid.buttons.flatMap((b) => [
448    Text({ children: "  " }),
449    Button({ key: b.key, label: b.label, hotkey: b.hotkey, onPress: () => pressButton($, b.key) }),
450  ]);
451  if (laid.row === "below") {
452    // The line keeps its row; the buttons take the next one, indented under the forecast word.
453    return Box({ flexDirection: "column", paddingX: 1, children: [
454      Box({ flexDirection: "row", children: line }),
455      Box({ flexDirection: "row", children: [Text({ children: " " }), ...buttons] }),
456    ] });
457  }
458  return Box({ flexDirection: "row", paddingX: 1, children: [...line, ...buttons] });
459}
460
461function drawPane($, e) {
462  const { Box, Text, Button } = $.ui.resolve(e);
463  const size = { columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 60, rows: e.viewport?.rows ?? 24 };
464  const rows = paneRows(state, opts, size, Date.now()).map((r) => Text(textProps(r)));
465  const close = Button({ key: "close", label: "close", role: "dismiss", onPress: () => $.ui.close({ id: PANE_ID }) });
466  return Box({ flexDirection: "column", paddingX: 1, children: [...rows, Text({ children: "" }), close] });
467}
468
469function message(error) {
470  return error instanceof Error ? error.message : String(error);
471}
472
hooks/options.mjs 95 lines
1// Pure: the mod's options, parsed once in register() into one frozen config.
2// Every key is a userConfig field in .claude-plugin/plugin.json; the defaults
3// here match the manifest's. Actions are off by default, hints on.
4
5import { CONTEXT_WARN_TOKENS, CONTEXT_DANGER_TOKENS } from "./usage-status.mjs";
6
7export const DEFAULTS = Object.freeze({
8  placement: "above",
9  warnTokens: CONTEXT_WARN_TOKENS,
10  dangerTokens: CONTEXT_DANGER_TOKENS,
11  suggestCompact: true,
12  buttons: true,
13  showDailyCost: true,
14  showAgents: true,
15  compactAt: 0,
16  compactConfirm: true,
17  compactFocus: "",
18  notify: false,
19  rateWarnPercent: 90,
20  sound: "off",
21  guardRateLimit: 0,
22  costLimitUsd: 0,
23  downshiftModel: "",
24  downshiftAt: 90,
25  pane: false,
26});
27
28const PLACEMENTS = ["above", "below"];
29const SOUNDS = ["off", "beep", "speak"];
30
31/** The options as the engine hands them, made whole: defaults filled, bad values replaced. */
32export function parseOptions(options) {
33  const o = options ?? {};
34  const warn = positive(o.warnTokens, DEFAULTS.warnTokens);
35  const danger = Math.max(warn, positive(o.dangerTokens, DEFAULTS.dangerTokens));
36  return Object.freeze({
37    placement: pick(o.placement, PLACEMENTS, DEFAULTS.placement),
38    warnTokens: warn,
39    dangerTokens: danger,
40    suggestCompact: bool(o.suggestCompact, DEFAULTS.suggestCompact),
41    buttons: bool(o.buttons, DEFAULTS.buttons),
42    showDailyCost: bool(o.showDailyCost, DEFAULTS.showDailyCost),
43    showAgents: bool(o.showAgents, DEFAULTS.showAgents),
44    compactAt: nonNegative(o.compactAt, DEFAULTS.compactAt),
45    compactConfirm: bool(o.compactConfirm, DEFAULTS.compactConfirm),
46    compactFocus: text(o.compactFocus, DEFAULTS.compactFocus),
47    notify: bool(o.notify, DEFAULTS.notify),
48    rateWarnPercent: percent(o.rateWarnPercent, DEFAULTS.rateWarnPercent),
49    sound: pick(o.sound, SOUNDS, DEFAULTS.sound),
50    guardRateLimit: percent(o.guardRateLimit, DEFAULTS.guardRateLimit),
51    costLimitUsd: nonNegative(o.costLimitUsd, DEFAULTS.costLimitUsd),
52    downshiftModel: text(o.downshiftModel, DEFAULTS.downshiftModel),
53    downshiftAt: percent(o.downshiftAt, DEFAULTS.downshiftAt),
54    pane: bool(o.pane, DEFAULTS.pane),
55  });
56}
57
58function number(value) {
59  const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
60  return typeof n === "number" && Number.isFinite(n) ? n : undefined;
61}
62
63function positive(value, fallback) {
64  const n = number(value);
65  return n !== undefined && n > 0 ? n : fallback;
66}
67
68function nonNegative(value, fallback) {
69  const n = number(value);
70  return n !== undefined && n >= 0 ? n : fallback;
71}
72
73// 0 means off; otherwise a percentage clamped to 1..100.
74function percent(value, fallback) {
75  const n = number(value);
76  if (n === undefined || n < 0) return fallback;
77  if (n === 0) return 0;
78  return Math.min(100, Math.max(1, n));
79}
80
81function bool(value, fallback) {
82  if (typeof value === "boolean") return value;
83  if (value === "true") return true;
84  if (value === "false") return false;
85  return fallback;
86}
87
88function text(value, fallback) {
89  return typeof value === "string" ? value.trim() : fallback;
90}
91
92function pick(value, allowed, fallback) {
93  return allowed.includes(value) ? value : fallback;
94}
95
hooks/readings.mjs 78 lines
1// Pure: the context readings the band and the pane draw from, and the lines
2// a reading crosses. Nothing here touches $.
3
4export const HISTORY_BAND = 12;
5export const HISTORY_PANE = 200;
6const BARS = "▁▂▃▄▅▆▇█";
7
8// Forecast bands, by percent of the window used.
9// Single-width text symbols, not emoji: they line up in every terminal font.
10export const FORECAST = Object.freeze([
11  { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
12  { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
13  { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
14  { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
15  { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
16]);
17
18export function forecastFor(percent) {
19  return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1];
20}
21
22/** A new readings list with `reading` appended, zero readings dropped, the oldest trimmed past `keep`. */
23export function pushReading(readings, reading, keep = HISTORY_PANE) {
24  const kept = readings.filter((r) => r.tokens > 0);
25  const next = [...kept, reading];
26  return next.length > keep ? next.slice(-keep) : next;
27}
28
29/** One reading from a $.session.usage() context, or undefined when the window is unknown. */
30export function readingFrom(context) {
31  if (!context || !context.window) return undefined;
32  const tokens = context.tokens ?? 0;
33  const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
34  return { tokens, window: context.window, percent };
35}
36
37/** The lines (by name) that `tokens` crossed upward since `previous`; `0` lines never fire. */
38export function crossings(previous, tokens, lines) {
39  return Object.entries(lines)
40    .filter(([, at]) => at > 0 && previous < at && tokens >= at)
41    .map(([name]) => name);
42}
43
44/** Bars scale to the busiest reading shown, so growth shows at any fill level. */
45export function chart(readings, width = readings.length) {
46  const shown = readings.slice(-width);
47  const top = Math.max(...shown.map((r) => r.tokens), 1);
48  return shown.map((r) => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]).join("");
49}
50
51export function trendWord(readings) {
52  if (readings.length < 2) return "";
53  const delta = readings[readings.length - 1].tokens - readings[readings.length - 2].tokens;
54  if (delta > 0) return `▲ +${short(delta)}`;
55  if (delta < 0) return `▼ ${short(-delta)}`;
56  return "steady";
57}
58
59export function short(n) {
60  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
61  if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
62  return String(n);
63}
64
65/** Subagent usage summed into the tally: a new tally, never the old one changed. */
66export function addAgentUsage(tally, agentId, usage) {
67  const tokens = (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0)
68    + (usage?.cache_read_input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0);
69  const was = tally.agents[agentId] ?? { tokens: 0, turns: 0, model: undefined };
70  return {
71    tokens: tally.tokens + tokens,
72    turns: tally.turns + 1,
73    agents: { ...tally.agents, [agentId]: { tokens: was.tokens + tokens, turns: was.turns + 1, model: usage?.model ?? was.model } },
74  };
75}
76
77export const EMPTY_TALLY = Object.freeze({ tokens: 0, turns: 0, agents: Object.freeze({}) });
78
hooks/band.mjs 98 lines
1// Pure: what the band line holds, as parts { text, color?, bold?, dim? } the
2// entry module turns into Text elements, and the Buttons it adds.
3
4import { usageParts, contextColor, rateTag } from "./usage-status.mjs";
5import { forecastFor, chart, trendWord, short, HISTORY_BAND } from "./readings.mjs";
6import { usd } from "./spend.mjs";
7
8export const CHART_MIN_COLUMNS = 60;
9const SEP = "  · ";
10// Buttons as drawn, "[ compact ]" and so on, two spaces before each.
11export const BUTTON_CELLS = 4 * 2 + "[ compact ]".length + "[ context ]".length + "[ cost ]".length + "[ clear ]".length;
12// A band narrower than its own button row has no buttons.
13export const BUTTONS_MIN_COLUMNS = BUTTON_CELLS + 4;
14
15export const BUTTONS = Object.freeze([
16  { key: "compact", label: "compact", hotkey: "c" },
17  { key: "context", label: "context", hotkey: "x" },
18  { key: "cost", label: "cost", hotkey: "d" },
19  { key: "clear", label: "clear", hotkey: "n" },
20]);
21
22/** The band's text parts, in order, for a state with at least one reading. */
23export function bandParts(state, opts, columns, nowMs) {
24  const readings = state.readings;
25  // The live reading moves with every model request and command; the chart keeps one bar per turn.
26  const now = state.live ?? readings[readings.length - 1];
27  const f = forecastFor(now.percent);
28  const size = contextColor(now.tokens, opts.warnTokens, opts.dangerTokens);
29  const tag = rateTag(now.tokens, opts.warnTokens);
30  // `drop`: the order a part gives way when the band is too narrow (lowest first); none: never.
31  const head = [
32    { text: `${f.icon}  ${f.word}`, color: f.color, bold: true },
33    { text: `  ${now.percent}% of context`, drop: 9, instead: `  ${now.percent}%` },
34    { text: "  " },
35    { text: short(now.tokens), color: size, bold: true },
36    { text: ` / ${short(now.window)}`, dim: true, drop: 8 },
37    ...(tag ? [{ text: ` ${tag}`, color: size, bold: true, drop: 7 }] : []),
38  ];
39  const trend = trendWord(readings);
40  const graph = columns >= CHART_MIN_COLUMNS
41    ? [
42        { text: "   last turns ", dim: true, drop: 5 },
43        { text: chart(readings, HISTORY_BAND), color: f.color, drop: 5 },
44        ...(trend ? [{ text: `  ${trend}`, dim: true, drop: 4 }] : []),
45      ]
46    : [];
47  const tail = [
48    ...usageParts(state.usage, nowMs).map((p) => ({ text: p.text, color: p.color, bold: Boolean(p.color), drop: 6 })),
49    ...spendPart(state.spend, opts),
50    ...agentsPart(state.agents, opts),
51  ].flatMap((p) => [{ text: SEP, dim: true, drop: p.drop }, p]);
52  return [...head, ...graph, ...tail];
53}
54
55/** The parts that fit in `columns` cells, `reserve` of them kept for the buttons: parts give way by their `drop` rank. */
56export function fitParts(parts, columns, reserve) {
57  const room = Math.max(0, columns - reserve);
58  const width = (list) => list.reduce((n, p) => n + [...p.text].length, 0);
59  const ranks = [...new Set(parts.filter((p) => p.drop).map((p) => p.drop))].sort((a, b) => a - b);
60  return ranks.reduce((kept, rank) => {
61    if (width(kept) <= room) return kept;
62    return kept.flatMap((p) => (p.drop !== rank ? [p] : p.instead ? [{ ...p, text: p.instead, drop: undefined, instead: undefined }] : []));
63  }, parts);
64}
65
66function spendPart(spend, opts) {
67  if (!opts.showDailyCost || !spend || (spend.today <= 0 && spend.week <= 0)) return [];
68  return [{ text: `today ${usd(spend.today)} · wk ${usd(spend.week)}`, dim: true, drop: 2 }];
69}
70
71function agentsPart(tally, opts) {
72  if (!opts.showAgents || !tally || tally.tokens <= 0) return [];
73  return [{ text: `agents ${short(tally.tokens)}`, dim: true, drop: 1 }];
74}
75
76/** The Buttons to draw after the line: none when off, narrow, or while a turn runs. */
77export function buttonSpecs(opts, columns, isWorking) {
78  if (!opts.buttons || isWorking || columns < BUTTONS_MIN_COLUMNS) return [];
79  return BUTTONS.map((b) => ({ ...b }));
80}
81
82// Parts of this rank or above (the windows, the cost, the count) outrank the buttons.
83const KEEP_OVER_BUTTONS = 6;
84
85/**
86 * The line and the buttons laid out in `columns`: beside each other when they fit with the
87 * windows and the cost, else the buttons on a row of their own (`row: "below"`). A band
88 * shorter than its tree scrolls, so the second row is never dropped for want of rows.
89 */
90export function layoutBand(parts, columns, specs, padding = 2) {
91  if (specs.length === 0) return { parts: fitParts(parts, columns, padding), buttons: [], row: "same" };
92  const withButtons = fitParts(parts, columns, padding + BUTTON_CELLS);
93  const kept = withButtons.filter((p) => p.drop >= KEEP_OVER_BUTTONS).length;
94  const wanted = parts.filter((p) => p.drop >= KEEP_OVER_BUTTONS).length;
95  if (kept >= wanted) return { parts: withButtons, buttons: specs, row: "same" };
96  return { parts: fitParts(parts, columns, padding), buttons: specs, row: "below" };
97}
98
hooks/guards.mjs 55 lines
1// Pure: the verdicts behind the actions. The entry module asks, drops,
2// toasts and runs commands on what these return.
3
4import { formatPercent } from "./usage-status.mjs";
5import { usd } from "./spend.mjs";
6
7const PERSON_ORIGINS = ["composer", "bridge", "sdk"];
8const LABEL = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
9
10function window(rateLimits, kind) {
11  return (rateLimits ?? []).find((w) => w && w.kind === kind && typeof w.percentUsed === "number");
12}
13
14/** Why a prompt should wait, as a question and a drop reason; undefined lets it through. */
15export function guardVerdict(usage, opts, originKind) {
16  if (!PERSON_ORIGINS.includes(originKind)) return undefined;
17  const five = window(usage?.rateLimits, "five_hour");
18  if (opts.guardRateLimit > 0 && five && five.percentUsed >= opts.guardRateLimit) {
19    const pct = `${formatPercent(five.percentUsed)}%`;
20    return { question: `5h window at ${pct}. Send anyway?`, reason: `held back: 5h window at ${pct}` };
21  }
22  const cost = usage?.cost?.usd;
23  if (opts.costLimitUsd > 0 && typeof cost === "number" && cost >= opts.costLimitUsd) {
24    return {
25      question: `Session cost ${usd(cost)} is past the ${usd(opts.costLimitUsd)} cap. Send anyway?`,
26      reason: `held back: session cost ${usd(cost)} past the ${usd(opts.costLimitUsd)} cap`,
27    };
28  }
29  return undefined;
30}
31
32/** Windows that just reached `percent`: { fired: kinds[], state: { kind: isFired } }; 0 never fires. */
33export function rateCrossings(fired, rateLimits, percent) {
34  const windows = (rateLimits ?? []).filter((w) => w && typeof w.percentUsed === "number");
35  const state = windows.reduce((acc, w) => ({ ...acc, [w.kind]: percent > 0 && w.percentUsed >= percent }), { ...fired });
36  const now = windows.filter((w) => state[w.kind] && !fired[w.kind]).map((w) => w.kind);
37  return { fired: now, state };
38}
39
40export function windowLabel(kind) {
41  return LABEL[kind] ?? kind;
42}
43
44/** The window that calls for the downshift, or undefined. */
45export function downshiftDue(rateLimits, opts) {
46  if (!opts.downshiftModel) return undefined;
47  const w = window(rateLimits, "seven_day") ?? window(rateLimits, "five_hour");
48  if (!w || w.percentUsed < opts.downshiftAt) return undefined;
49  return { window: windowLabel(w.kind), percent: w.percentUsed };
50}
51
52export function costWarningDue(cost, opts) {
53  return opts.costLimitUsd > 0 && typeof cost === "number" && cost >= 0.8 * opts.costLimitUsd;
54}
55
hooks/spend.mjs 54 lines
1// Pure: the spend ledger's keys and sums. The entry module reads and writes
2// $.store; each session writes only its own key, so concurrent sessions
3// never overwrite each other. Rows: { day, week, usd, at }.
4
5export const SPEND_PREFIX = "spend:";
6const DAY_MS = 86_400_000;
7const KEEP_MS = 8 * 7 * DAY_MS;
8
9export function spendKey(sessionId) {
10  return `${SPEND_PREFIX}${sessionId}`;
11}
12
13/** The row a session stores: its cost so far, stamped with the day and week it started counting. */
14export function spendEntry(usd, nowMs) {
15  return { day: dayKey(nowMs), week: isoWeek(nowMs), usd, at: nowMs };
16}
17
18export function dayKey(ms) {
19  return new Date(ms).toISOString().slice(0, 10);
20}
21
22/** ISO 8601 week, `2026-W41`, in UTC. */
23export function isoWeek(ms) {
24  const d = new Date(ms);
25  const day = d.getUTCDay() || 7;
26  const thursday = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 4 - day));
27  const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1);
28  const week = Math.ceil(((thursday.getTime() - yearStart) / DAY_MS + 1) / 7);
29  return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
30}
31
32/** Today's and this week's total over every session's row. */
33export function sumSpend(entries, nowMs) {
34  const today = dayKey(nowMs);
35  const week = isoWeek(nowMs);
36  return entries
37    .filter((e) => e && typeof e.usd === "number" && Number.isFinite(e.usd))
38    .reduce((acc, e) => ({
39      today: acc.today + (e.day === today ? e.usd : 0),
40      week: acc.week + (e.week === week ? e.usd : 0),
41    }), { today: 0, week: 0 });
42}
43
44/** The spend keys whose row is older than eight weeks: safe to delete. */
45export function staleKeys(rows, nowMs) {
46  return Object.entries(rows)
47    .filter(([key, row]) => key.startsWith(SPEND_PREFIX) && (!row || typeof row.at !== "number" || nowMs - row.at > KEEP_MS))
48    .map(([key]) => key);
49}
50
51export function usd(n) {
52  return `$${n.toFixed(2)}`;
53}
54
hooks/weather-command.mjs 106 lines
1// Pure: the text /weather prints. The entry module gathers the view and
2// dispatches the subcommands.
3
4import { usageParts } from "./usage-status.mjs";
5import { forecastFor, short, trendWord, HISTORY_BAND } from "./readings.mjs";
6import { usd, isoWeek } from "./spend.mjs";
7
8export const SUBCOMMANDS = Object.freeze(["report", "reset", "pane", "compact", "ledger", "help"]);
9
10export const HELP = [
11  "/weather           the forecast, windows, spend, agents, context categories",
12  "/weather reset     forget the readings and re-arm the toasts and actions",
13  "/weather pane      open or close the weather pane",
14  "/weather compact   compact now, with the compactFocus instructions",
15  "/weather ledger    spend by day over the stored sessions",
16].join("\n");
17
18export function parseWeatherArgs(args) {
19  const word = (args ?? "").trim().toLowerCase();
20  if (word === "") return "report";
21  return SUBCOMMANDS.includes(word) ? word : "help";
22}
23
24/** The report for a view { readings, usage, spend, agents, categories?, model? }. */
25export function weatherReport(view, opts, nowMs) {
26  return [
27    ...headline(view),
28    ...recentTurns(view.readings),
29    ...windows(view.usage, nowMs),
30    ...spend(view, opts),
31    ...agents(view.agents),
32    ...categories(view.categories),
33    ...thresholds(opts),
34  ].join("\n");
35}
36
37function headline(view) {
38  const now = view.live ?? view.readings[view.readings.length - 1];
39  if (!now) return ["token weather: no reading yet (the first API response brings one)"];
40  const f = forecastFor(now.percent);
41  const model = view.model ? `  ${view.model}` : "";
42  return [`${f.icon} ${f.word}  ${now.percent}% of context  ${short(now.tokens)} / ${short(now.window)}${model}`];
43}
44
45function recentTurns(readings) {
46  if (readings.length < 2) return [];
47  const shown = readings.slice(-HISTORY_BAND);
48  const rows = shown.map((r, i) => {
49    const prior = shown.slice(0, i + 1);
50    const trend = i === 0 ? "" : `  ${trendWord(prior)}`;
51    return `  ${String(short(r.tokens)).padStart(7)}${trend}`;
52  });
53  return ["", `last turns (${shown.length})`, ...rows];
54}
55
56function windows(usage, nowMs) {
57  const parts = usageParts({ rateLimits: usage?.rateLimits ?? [] }, nowMs);
58  return parts.length ? ["", "windows", ...parts.map((p) => `  ${p.text}`)] : [];
59}
60
61function spend(view, opts) {
62  const session = view.usage?.cost?.usd;
63  const rows = [
64    ...(typeof session === "number" ? [`  session ${usd(session)}`] : []),
65    ...(opts.showDailyCost && view.spend ? [`  today ${usd(view.spend.today)}`, `  week ${usd(view.spend.week)}`] : []),
66  ];
67  return rows.length ? ["", "cost", ...rows] : [];
68}
69
70function agents(tally) {
71  if (!tally || tally.tokens <= 0) return [];
72  const rows = Object.entries(tally.agents).slice(-20).map(([id, a]) => `  ${id}  ${short(a.tokens)}  ${a.turns} turns  ${a.model ?? ""}`.trimEnd());
73  return ["", `agents ${short(tally.tokens)} over ${tally.turns} turns`, ...rows];
74}
75
76function categories(list) {
77  const used = (list ?? []).filter((c) => c.kind === "used" && c.tokens > 0);
78  return used.length ? ["", "context", ...used.map((c) => `  ${c.name}  ${short(c.tokens)}`)] : [];
79}
80
81function thresholds(opts) {
82  const off = (on, text) => (on ? text : "off");
83  return [
84    "",
85    "thresholds",
86    `  warn ${short(opts.warnTokens)}  danger ${short(opts.dangerTokens)}`,
87    `  compact at ${off(opts.compactAt > 0, short(opts.compactAt))}${opts.compactAt > 0 && opts.compactConfirm ? " (asks first)" : ""}`,
88    `  rate guard ${off(opts.guardRateLimit > 0, `${opts.guardRateLimit}%`)}`,
89    `  cost cap ${off(opts.costLimitUsd > 0, usd(opts.costLimitUsd))}`,
90    `  downshift ${off(opts.downshiftModel !== "", `to ${opts.downshiftModel} at ${opts.downshiftAt}%`)}`,
91    `  notify ${opts.notify ? "on" : "off"}  sound ${opts.sound}  suggest /compact ${opts.suggestCompact ? "on" : "off"}`,
92  ];
93}
94
95/** Spend rows by day, newest first, then the week's total. */
96export function ledgerReport(entries, nowMs) {
97  const rows = entries.filter((e) => e && typeof e.usd === "number");
98  if (rows.length === 0) return "no spend recorded yet";
99  const byDay = rows.reduce((acc, e) => ({ ...acc, [e.day]: { usd: (acc[e.day]?.usd ?? 0) + e.usd, sessions: (acc[e.day]?.sessions ?? 0) + 1 } }), {});
100  const week = isoWeek(nowMs);
101  const weekTotal = rows.filter((e) => e.week === week).reduce((sum, e) => sum + e.usd, 0);
102  const days = Object.entries(byDay).sort(([a], [b]) => (a < b ? 1 : -1))
103    .map(([day, d]) => `${day}  ${usd(d.usd)}  ${d.sessions} session${d.sessions === 1 ? "" : "s"}`);
104  return [...days, "", `week ${week}  ${usd(weekTotal)}`].join("\n");
105}
106
hooks/pane.mjs 74 lines
1// Pure: the rows the weather pane draws, { text, color?, dim?, kind? }.
2
3import { usageParts, colorFor, formatPercent, resetIn } from "./usage-status.mjs";
4import { forecastFor, short } from "./readings.mjs";
5import { usd } from "./spend.mjs";
6
7const BARS = "▁▂▃▄▅▆▇█";
8const LABEL = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
9
10/** Readings bucketed to `width` cells, each cell the max of its bucket, scaled to the busiest. */
11export function sparkline(readings, width) {
12  if (readings.length === 0 || width <= 0) return "";
13  const per = Math.ceil(readings.length / width);
14  const buckets = Array.from({ length: Math.ceil(readings.length / per) }, (_, i) =>
15    Math.max(...readings.slice(i * per, (i + 1) * per).map((r) => r.tokens)));
16  const top = Math.max(...buckets, 1);
17  return buckets.map((t) => BARS[Math.min(BARS.length - 1, Math.floor((t / top) * (BARS.length - 1)))]).join("");
18}
19
20export function bar(percent, width) {
21  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width);
22  return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
23}
24
25export function paneRows(state, opts, size, nowMs) {
26  const width = Math.max(10, size.columns);
27  return [
28    ...headRows(state, opts),
29    ...chartRows(state.readings, width),
30    ...windowRows(state.usage, width, nowMs),
31    ...costRows(state, opts),
32  ];
33}
34
35function headRows(state, opts) {
36  const now = state.live ?? state.readings[state.readings.length - 1];
37  if (!now) return [{ text: "no reading yet", dim: true }];
38  const f = forecastFor(now.percent);
39  const over = now.tokens >= opts.warnTokens ? "  ⚠2x" : "";
40  return [{ text: `${f.icon} ${f.word}  ${now.percent}%  ${short(now.tokens)} / ${short(now.window)}${over}`, color: f.color, bold: true }];
41}
42
43function chartRows(readings, width) {
44  if (readings.length < 2) return [];
45  const line = sparkline(readings, width);
46  return [
47    { text: `turns ${readings.length}  peak ${short(Math.max(...readings.map((r) => r.tokens)))}`, dim: true },
48    { text: line, kind: "chart" },
49  ];
50}
51
52function windowRows(usage, width, nowMs) {
53  const limits = (usage?.rateLimits ?? []).filter((w) => w && typeof w.percentUsed === "number");
54  if (limits.length === 0) return [];
55  const barWidth = Math.max(4, Math.min(20, width - 22));
56  return limits.map((w) => {
57    const reset = resetIn(w.resetsAt, nowMs);
58    const label = (LABEL[w.kind] ?? w.kind).padEnd(5);
59    return { text: `${label}${bar(w.percentUsed, barWidth)} ${formatPercent(w.percentUsed)}%${reset ? ` ↻${reset}` : ""}`, color: colorFor(w.percentUsed) };
60  });
61}
62
63function costRows(state, opts) {
64  const cost = usageParts({ cost: state.usage?.cost }, 0)[0];
65  const spend = opts.showDailyCost && state.spend && (state.spend.today > 0 || state.spend.week > 0)
66    ? `  today ${usd(state.spend.today)}  week ${usd(state.spend.week)}`
67    : "";
68  const rows = cost ? [{ text: `session ${cost.text}${spend}`, dim: true }] : [];
69  const tally = state.agents;
70  return opts.showAgents && tally && tally.tokens > 0
71    ? [...rows, { text: `agents ${short(tally.tokens)} over ${tally.turns} turns`, dim: true }]
72    : rows;
73}
74
hooks/usage-status.mjs 98 lines
1// Pure helpers for the usage part of the band: no $ calls, so they test on their own.
2//
3// usageParts({ rateLimits, cost }, nowMs) ->
4//   [{ text: "5h 23% ↻2h10m", color: "green" }, { text: "7d 41% ↻3d4h", color: "green" }, { text: "$1.23" }]
5// Empty when there is nothing to show (no window reading, no cost).
6// A window is green below WARN_PERCENT, yellow below DANGER_PERCENT, red from there.
7
8const WINDOW_LABEL = {
9  five_hour: "5h",
10  seven_day: "7d",
11  spend_limit: "spend",
12};
13
14const WINDOW_ORDER = ["five_hour", "seven_day", "spend_limit"];
15const WARN_PERCENT = 50;
16const DANGER_PERCENT = 80;
17// Context size, in tokens: green below WARN, yellow below DANGER, red from there.
18export const CONTEXT_WARN_TOKENS = 200_000;
19export const CONTEXT_DANGER_TOKENS = 300_000;
20
21export function contextColor(tokens, warn = CONTEXT_WARN_TOKENS, danger = CONTEXT_DANGER_TOKENS) {
22  if (tokens >= danger) return "red";
23  if (tokens >= warn) return "yellow";
24  return "green";
25}
26
27/** "⚠2x" once the prompt is above the long-context line (1M-context models bill input higher there). */
28export function rateTag(tokens, warn = CONTEXT_WARN_TOKENS) {
29  return tokens >= warn ? "⚠2x" : undefined;
30}
31
32/** The two limits from the mod's options: positive numbers, else the defaults; danger never below warn. */
33export function thresholds(options) {
34  const warn = positive(options?.warnTokens, CONTEXT_WARN_TOKENS);
35  const danger = Math.max(warn, positive(options?.dangerTokens, CONTEXT_DANGER_TOKENS));
36  return { warn, danger };
37}
38
39function positive(value, fallback) {
40  const n = typeof value === "string" ? Number(value) : value;
41  return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : fallback;
42}
43
44export function usageParts(usage, nowMs) {
45  const windows = windowParts(usage?.rateLimits ?? [], nowMs);
46  const cost = costPart(usage?.cost);
47  return cost ? [...windows, cost] : windows;
48}
49
50function windowParts(rateLimits, nowMs) {
51  return [...rateLimits]
52    .filter((w) => w && typeof w.percentUsed === "number")
53    .sort((a, b) => rank(a.kind) - rank(b.kind))
54    .map((w) => windowPart(w, nowMs));
55}
56
57function rank(kind) {
58  const i = WINDOW_ORDER.indexOf(kind);
59  return i === -1 ? WINDOW_ORDER.length : i;
60}
61
62function windowPart(w, nowMs) {
63  const label = WINDOW_LABEL[w.kind] ?? w.kind;
64  const pct = `${formatPercent(w.percentUsed)}%`;
65  const reset = resetIn(w.resetsAt, nowMs);
66  const text = reset ? `${label} ${pct} ↻${reset}` : `${label} ${pct}`;
67  return { text, color: colorFor(w.percentUsed) };
68}
69
70export function colorFor(percentUsed) {
71  if (percentUsed >= DANGER_PERCENT) return "red";
72  if (percentUsed >= WARN_PERCENT) return "yellow";
73  return "green";
74}
75
76export function formatPercent(p) {
77  const n = Math.max(0, p);
78  return Number.isInteger(n) ? String(n) : n.toFixed(1);
79}
80
81export function resetIn(resetsAt, nowMs) {
82  if (!resetsAt) return undefined;
83  const at = Date.parse(resetsAt);
84  if (Number.isNaN(at)) return undefined;
85  const left = Math.max(0, Math.round((at - nowMs) / 60_000));
86  const days = Math.floor(left / 1440);
87  const hours = Math.floor((left % 1440) / 60);
88  const mins = left % 60;
89  if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`;
90  if (hours > 0) return `${hours}h${String(mins).padStart(2, "0")}m`;
91  return `${mins}m`;
92}
93
94function costPart(cost) {
95  if (!cost || typeof cost.usd !== "number") return undefined;
96  return { text: `$${cost.usd.toFixed(2)}` };
97}
98