SLOPSHOPPER

optimaizr

optimAIzr inside Claude Code: what the turn has cost and your 5-hour window while Claude works, what each model switch saved, the model and effort switches you…

newpanebandspinnerguardcommand
★ 27v0.10.1MITupdated 2026-10-07blendbunjaku/optimaizr/mods/optimaizr
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · optimaizr
│ ┃ optimaizr ✕ › fix the failing auth test and add an audit log call │ ┃ optimAIzr ● watching │ ┃ ⏺ Read(src/auth.ts) │ ┃ no switch yet · press Y in optimaizr live ⎿ Read 6 lines │ ┃ spent $0.42 · 0 requests ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 5h ███████░░░░░░░░░░░░░░░░░ 31% ⏺ Bash(bun test) │ ┃ resets 09:53 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Esc closes ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /optimaizr │ ⎿ optimaizr: Spend $0.42 at API rates over 0 requests since 0 │ ⎿ optimaizr: 5h window 31% · resets 09:53 │ ⎿ optimaizr: Switch none: accept one with Y in optimaizr live │ │ ✻ Thinking · 31% of 5h… optimAIzr █████░░░░░░░░░░░ 31% of 5h · resets 09:53 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
optimAIzr █████░░░░░░░░░░░ 31% of 5h · resets 09:53
Pane · optimaizr
optimAIzr ● watching no switch yet · press Y in optimaizr live spent $0.42 · 0 requests 5h ███████░░░░░░░░░░░░░░░░░ 31% resets 09:53 Esc closes
README

optimAIzr for Claude Code

A Claude Code mod from optimAIzr. It puts what your session costs where you work, lets optimaizr live switch the model or effort of the session you are in, and shows what each switch saved.

/plugin marketplace add blendbunjaku/optimaizr
/plugin install optimaizr@optimaizr

Needs Claude Code 2.1.287 or later.

What it adds

  • The spinner shows what the turn has cost so far and how full your 5-hour window is: Thinking · $0.18 · 61% of 5h…
  • The band above the prompt shows the window, how long it lasts at this pace and when it resets: 61% of 5h · ~2h 25m left at this pace · resets 01:00
  • Each answer gets one line: optimaizr: this turn $0.18 · 4 requests · 5h 55% → 56%
  • What a switch saved: a switched turn's line leads with it, e.g. saved $0.10 vs Opus 5.5 · this turn $0.10 on Sonnet 5.5 · …, and the band keeps a running total. It is the same tokens priced on both models.
  • /optimaizr prints the session's spend, what it saved, both plan windows and any switch. /optimaizr hud shows it as gauges and a sparkline.
  • Switches that pay: subagents switch at once; a long conversation waits until reloading it into the new model pays back, and the band says so.
  • Before the cache expires: Claude Code caches the conversation for an hour. Once it carries 50K tokens or more, the band counts down the last 15 minutes, cache warm 10m more, then 300K is written again ($2.40), and one toast comes 5 minutes before. After it expires, the band says what the next message will cost. Past 200K, it says what each request re-reads.
  • /optimaizr handoff: Claude writes a short note (what changed, what was decided, what is open, what to check first) while the cache is still warm, so it costs cents. /clear, and the next conversation in that project starts from the note instead of re-reading the old one. A note is used once and expires after 12 hours.
  • A guard against retry loops: when the same command fails twice in a row with nothing changed, the next identical attempt is held once and Claude is asked to change something first.
  • Switches. Press Y on a model swap in optimaizr live and running sessions in that project use the new model from their next request. A reasoning-effort finding lowers effort the same way. The first switched request leaves a note, and the band names the model. Harder task? /optimaizr off goes back for this session, /optimaizr on resumes, and optimaizr undo <rule> takes it back everywhere.

Options

OptionDefaultWhat it does
turnLinetrueThe line under each answer with what it cost.
retryGuardtrueHold a command that failed twice unchanged.

Privacy

It reads the usage figures Claude Code already shows you (cost, token counts, context fill, plan windows) and the commands Claude runs, kept in memory for the retry guard. It never reads your prompts or file contents and makes no network calls of its own. It reads ~/.optimaizr/overrides.json and writes one small file per session to ~/.optimaizr/mod/sessions, which is how optimaizr live knows it is running. That file also carries Claude Code's own 5-hour and weekly meters, so optimaizr profile and optimaizr live can show your real windows. /optimaizr handoff is the one exception to "no text": only when you run it, it asks Claude for a note through your own session and saves the reply to ~/.optimaizr/mod/handoffs, one file per project, which the next conversation there reads once. Nothing leaves your machine besides that one request to Claude. claude plugin validate on this folder lists every call it makes.

Develop

claude --plugin-dir mods/optimaizr     # reloads on save
claude plugin validate mods/optimaizr
claude plugin test mods/optimaizr

The tests were last run on Claude Code 2.1.286, where mods still needed CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. From 2.1.287 they are on by default.

Source 3 files
hooks/register.tsx 998 lines
1import type {
2  EngineInterface,
3  Register,
4  SessionUsage,
5  TurnStepInput,
6  TurnStepResult,
7} from "claude-code";
8import {
9  bar,
10  cacheNote,
11  carryCost,
12  costAt,
13  crossed,
14  describeSwitch,
15  duration,
16  effortFor,
17  EXPIRY_TOAST_MS,
18  EXPIRY_TOAST_USD,
19  fiveHour,
20  type Handoff,
21  handoffBlock,
22  handoffFile,
23  handoffFor,
24  HANDOFF_PROMPT,
25  inProject,
26  isWindow,
27  kTokens,
28  LONG_CONTEXT,
29  meterColor,
30  meterText,
31  modelLabel,
32  type Override,
33  parseOverrides,
34  PAYBACK_REQUESTS,
35  paybackRequests,
36  percent,
37  projectName,
38  record,
39  reloadCost,
40  SAVED_MILESTONES,
41  savedBy,
42  sevenDay,
43  share,
44  sparkline,
45  summary,
46  switchFor,
47  turnLine,
48  usd,
49  type Tokens,
50  type Window,
51  WINDOW_ALERTS,
52  windowNote,
53  windowShare,
54  windowsOf,
55} from "./meter.ts";
56
57// The optimAIzr mod: this turn's cost and the 5-hour window while Claude works,
58// one line under each answer, the model and effort switches `optimaizr live`
59// accepts, a guard against retry loops, and a countdown before the cache expires.
60// It reads usage figures and the commands Claude runs, never prompt text or file
61// contents. The one text it keeps is the note /optimaizr handoff asks Claude for.
62
63const VERSION = "0.10.1";
64// The HUD /optimaizr hud opens beside the conversation.
65const PANE = "optimaizr";
66// overrides.json is read again at most this often, so `optimaizr undo` lands fast.
67const REREAD_MS = 3_000;
68// `optimaizr live` counts a session as gone after three missed beats.
69const BEAT_MS = 60_000;
70// Claude Code caches the main conversation for an hour; after that a reload is paid either way.
71const CACHE_TTL_MS = 55 * 60_000;
72// A command that failed this many times in a row, unchanged, is held once.
73const RETRY_LIMIT = 2;
74
75// Tools whose success can change what a retried command sees.
76const MUTATING = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"]);
77
78type Api = EngineInterface;
79
80type Turn = {
81  startUsd: number;
82  startPct?: number;
83  calls: number;
84  switched?: Override;
85  saved?: number;
86  effort?: string;
87  held: number;
88};
89
90// One session's figures, shared by the hooks below.
91const state = {
92  dir: "",
93  file: "",
94  id: "",
95  // The project root: where the session started. A shell `cd` doesn't move it.
96  root: "",
97  startedAt: 0,
98  requests: 0,
99  usage: null as SessionUsage | null,
100  window: undefined as Window | undefined,
101  turn: null as Turn | null,
102  overrides: [] as Override[],
103  readAt: -Infinity,
104  // Switches that moved at least one request in this session, by slot.
105  applied: new Map<string, Override>(),
106  // Targets the API refused here; the session keeps its own setting for them.
107  refused: new Set<string>(),
108  // `/optimaizr off`: this session goes back to its own model until `/optimaizr on`.
109  paused: false,
110  // Switches the person has been told about, so the note shows once.
111  told: new Set<string>(),
112  // Net saving of the switched requests, once one has been priced.
113  saved: undefined as number | undefined,
114  held: 0,
115  // The session's average unswitched main request, which a switch is weighed against.
116  avg: { n: 0, input: 0, output: 0, read: 0, write: 0 },
117  // When the main conversation last made a request; past the cache's lifetime, it reloads anyway.
118  lastMainAt: null as number | null,
119  // The model that request went to, whose cache holds the conversation.
120  model: "",
121  // Counts /clear and /resume, so a handoff note is never read back where it was written.
122  conversation: 0,
123  // The idle stretch, by its last request, already warned about the cache expiring.
124  expiryTold: null as number | null,
125  // A main-conversation switch held back because its reload wouldn't pay back yet.
126  waiting: null as { o: Override; reload: number; payback: number } | null,
127  // Token totals since the mod loaded, for the cache-hit share.
128  tokens: { input: 0, read: 0, write: 0 },
129  // What switched requests cost, and what they would have cost unswitched.
130  switchedWas: 0,
131  switchedIs: 0,
132  // Session cost and 5-hour reading when the mod loaded, for the burn rate and window share.
133  startUsd: null as number | null,
134  startPct: null as number | null,
135  // What each finished turn cost, newest last, for the pane's sparkline.
136  turnCosts: [] as number[],
137  // Window levels already toasted, by reset time.
138  warned: new Set<string>(),
139  // What this conversation has seen; compaction and /clear forget it.
140  convo: {
141    // Models that already wrote this conversation to their cache.
142    loaded: new Set<string>(),
143    fails: new Map<string, number>(),
144    // Held once already: the next identical command goes through.
145    waived: new Set<string>(),
146    // The handoff block this conversation started from, kept so a re-read gets the same one.
147    handoff: null as string | null,
148    // Told once that the conversation passed LONG_CONTEXT.
149    long: false,
150  },
151};
152
153function forget(): void {
154  state.convo = {
155    loaded: new Set(),
156    fails: new Map(),
157    waived: new Set(),
158    handoff: null,
159    long: false,
160  };
161}
162
163async function optimaizrDir($: Api): Promise<string> {
164  const set = await $.env.get("OPTIMAIZR_DIR");
165  if (set) return set;
166  const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? ".";
167  return `${home}/.optimaizr`;
168}
169
170/** Keep the latest usage, add a 5-hour reading to the shared history, redraw. */
171async function take($: Api, u: SessionUsage): Promise<void> {
172  const was = fiveHour(state.usage?.rateLimits)?.percentUsed;
173  state.usage = u;
174  const five = fiveHour(u.rateLimits);
175  if (five && was !== undefined) {
176    const level = crossed(WINDOW_ALERTS, was, five.percentUsed);
177    const key = `${five.resetsAt ?? ""}:${level}`;
178    if (level !== null && !state.warned.has(key)) {
179      state.warned.add(key);
180      void $.ui.toast(
181        `optimAIzr: 5h window at ${percent(five.percentUsed)}${windowNote(five, state.window, await $.clock.now())}`,
182        { timeoutMs: 8_000 },
183      );
184    }
185  }
186  // The CLI reads the meters from the session file, so a move is written at once.
187  if (five && five.percentUsed !== was) await beat($).catch(() => undefined);
188  if (five) {
189    const stored = await $.store.get("window");
190    const prev = isWindow(stored) ? stored : state.window;
191    const next = record(prev, five, await $.clock.now());
192    state.window = next;
193    if (next !== prev) await $.store.set("window", next);
194  }
195  void $.ui.invalidate("ui.render");
196}
197
198async function measure($: Api): Promise<void> {
199  await take($, await $.session.usage());
200}
201
202/** The session file `optimaizr live` and `optimaizr mod` read. */
203async function beat($: Api, ended = false): Promise<void> {
204  if (!state.file) return;
205  const at = await $.clock.now();
206  const now = new Date(at).toISOString();
207  const windows = windowsOf(state.usage?.rateLimits, at);
208  const body = {
209    id: state.id,
210    version: VERSION,
211    cwd: state.root,
212    startedAt: new Date(state.startedAt).toISOString(),
213    seenAt: now,
214    ...(ended ? { endedAt: now } : {}),
215    ...(windows ? { windows } : {}),
216  };
217  await $.fs.write(state.file, `${JSON.stringify(body)}\n`);
218}
219
220/** Once a minute: tell the CLI the session is alive, and notice an undo while idle. */
221async function refresh($: Api): Promise<void> {
222  await beat($).catch(() => undefined);
223  await switches($).catch(() => undefined);
224  await expiring($).catch(() => undefined);
225  void $.ui.invalidate("ui.render");
226}
227
228/** What to say about the main conversation's cache while the prompt waits. */
229async function cacheNow($: Api): Promise<{ label: string; text: string } | null> {
230  if (state.turn || state.lastMainAt === null || !state.model) return null;
231  return cacheNote({
232    model: state.model,
233    context: state.usage?.context.tokens ?? 0,
234    idleMs: (await $.clock.now()) - state.lastMainAt,
235    ttlMs: CACHE_TTL_MS,
236  });
237}
238
239/** One toast shortly before an idle conversation's cache expires, when coming back would cost. */
240async function expiring($: Api): Promise<void> {
241  const at = state.lastMainAt;
242  if (at === null || state.turn || state.expiryTold === at) return;
243  const left = at + CACHE_TTL_MS - (await $.clock.now());
244  const context = state.usage?.context.tokens ?? 0;
245  const cost = carryCost(state.model, context);
246  if (left <= 0 || left > EXPIRY_TOAST_MS || !cost || cost.rewrite < EXPIRY_TOAST_USD) return;
247  state.expiryTold = at;
248  void $.ui.toast(
249    `optimAIzr: the cache expires in ${duration(left)}. Coming back after that writes ${kTokens(context)} again (${usd(cost.rewrite)}). Leaving? /optimaizr handoff, then /clear.`,
250    { timeoutMs: 10_000 },
251  );
252}
253
254/** /optimaizr handoff: Claude writes a note from the warm cache, kept for the next conversation here. */
255async function handoff($: Api): Promise<string> {
256  if (state.turn) return "Claude is still working. Run /optimaizr handoff once the turn ends.";
257  const r = await $.model.fork({ prompt: HANDOFF_PROMPT });
258  if (!r.isAnswered) {
259    if (r.reason === "nothing-to-fork") {
260      return "Nothing to hand off yet: Claude hasn't answered in this conversation.";
261    }
262    if (r.reason === "api-error") {
263      return `Couldn't write the note: the API answered ${r.status ?? "nothing"} (${r.error}). Try again in a moment.`;
264    }
265    return r.reason === "aborted"
266      ? "The note was cancelled."
267      : "Claude answered without a note. Try again.";
268  }
269  const now = await $.clock.now();
270  const note: Handoff = {
271    version: 1,
272    root: state.root,
273    session: state.id,
274    conversation: state.conversation,
275    at: new Date(now).toISOString(),
276    text: r.text.trim(),
277  };
278  await $.fs.write(handoffFile(state.dir, state.root), `${JSON.stringify(note, null, 2)}\n`);
279
280  // Mostly read from cache, it cost little; otherwise the cache had expired.
281  const u = r.usage;
282  const cost = costAt(state.model, u, { hour: true });
283  const warm = u.cache_read_input_tokens >= u.input_tokens + u.cache_creation_input_tokens;
284  const how =
285    cost === null
286      ? ""
287      : warm
288        ? `, written from the warm cache for ${usd(cost)}`
289        : `, for ${usd(cost)}: the cache had expired, so Claude read the conversation again`;
290  return (
291    `Handoff saved for ${projectName(state.root)}${how}.\n\n${note.text}\n\n` +
292    `/clear now and the next conversation in this project starts from this note. ` +
293    `It is used once, within 12 hours.`
294  );
295}
296
297/** The handoff note a new conversation here starts from, claimed once. */
298async function startFrom($: Api): Promise<string | null> {
299  if (state.convo.handoff !== null) return state.convo.handoff;
300  const dir = state.dir || (await optimaizrDir($));
301  const root = state.root || (await $.session.root());
302  const file = handoffFile(dir, root);
303  const text = await $.fs.read(file).catch(() => "");
304  let stored: unknown = null;
305  try {
306    stored = JSON.parse(typeof text === "string" ? text : "");
307  } catch {
308    return null;
309  }
310  const now = await $.clock.now();
311  const h = handoffFor(stored, {
312    root,
313    session: state.id,
314    conversation: state.conversation,
315    now,
316  });
317  if (!h) return null;
318  await $.fs.write(
319    file,
320    `${JSON.stringify({ ...h, usedAt: new Date(now).toISOString() }, null, 2)}\n`,
321  );
322  state.convo.handoff = handoffBlock(h);
323  void $.ui.toast("optimAIzr: this conversation starts from your handoff note", {
324    timeoutMs: 6_000,
325  });
326  return state.convo.handoff;
327}
328
329async function switches($: Api): Promise<Override[]> {
330  const now = await $.clock.now();
331  if (now - state.readAt < REREAD_MS) return state.overrides;
332  state.readAt = now;
333  const text = await $.fs.read(`${state.dir}/overrides.json`).catch(() => "");
334  state.overrides = parseOverrides(typeof text === "string" ? text : "");
335  return state.overrides;
336}
337
338/** Fold an unswitched main request into the session's average. */
339function learn(u: Tokens): void {
340  const a = state.avg;
341  a.n += 1;
342  a.input += (u.input_tokens - a.input) / a.n;
343  a.output += (u.output_tokens - a.output) / a.n;
344  a.read += (u.cache_read_input_tokens - a.read) / a.n;
345  a.write += (u.cache_creation_input_tokens - a.write) / a.n;
346}
347
348/** The average request, or a modest one before there is any to go on. */
349function average(contextTokens: number): Tokens {
350  const a = state.avg;
351  return a.n > 0
352    ? {
353        input_tokens: a.input,
354        output_tokens: a.output,
355        cache_read_input_tokens: a.read,
356        cache_creation_input_tokens: a.write,
357      }
358    : {
359        input_tokens: 0,
360        output_tokens: 500,
361        cache_read_input_tokens: contextTokens,
362        cache_creation_input_tokens: 1_000,
363      };
364}
365
366/** Count a finished request and refresh the figures the spinner shows. */
367async function counted($: Api, u?: Tokens | null): Promise<void> {
368  if (u) {
369    state.tokens.input += u.input_tokens;
370    state.tokens.read += u.cache_read_input_tokens;
371    state.tokens.write += u.cache_creation_input_tokens;
372  }
373  state.requests += 1;
374  if (state.turn) state.turn.calls += 1;
375  await measure($).catch(() => undefined);
376}
377
378const slotOf = (o: Override) =>
379  `${o.project}\u0000${o.subagent ?? ""}\u0000${o.from}\u0000${o.effort ?? ""}`;
380
381// No response and no usage, and not because the person interrupted: the request failed.
382const failed = (r: TurnStepResult, aborted: boolean) =>
383  !aborted && r.stopReason === null && r.usage === null;
384
385const clip = (s: string, n = 60) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
386
387const commandKey = (agent: string, command: string) =>
388  `${agent}\u0000${command.trim().replace(/\s+/g, " ")}`;
389
390export const register: Register = (on, options) => {
391  const showTurnLine = options.turnLine !== false;
392  const retryGuard = options.retryGuard !== false;
393
394  on("session.start", async ($, e, next) => {
395    state.dir = await optimaizrDir($);
396    state.id = await $.session.id();
397    state.root = await $.session.root();
398    state.startedAt = await $.clock.now();
399    state.file = `${state.dir}/mod/sessions/${state.id}.json`;
400    await $.command.register({
401      name: "optimaizr",
402      description:
403        "This session's spend, savings and plan windows; hud opens the HUD, handoff saves a note to start fresh from, off or on pauses a switch",
404      argumentHint: "[hud|handoff|off|on]",
405      immediate: true,
406    });
407    await beat($).catch(() => undefined);
408    $.clock.every(BEAT_MS, () => void refresh($));
409    await measure($).catch(() => undefined);
410    state.startUsd = state.usage?.cost?.usd ?? null;
411    state.startPct = fiveHour(state.usage?.rateLimits)?.percentUsed ?? null;
412    return next(e);
413  });
414
415  // /clear and /resume end a conversation, not the session the mod runs in.
416  on("session.end", async ($, e, next) => {
417    forget();
418    if (e.reason === "clear" || e.reason === "resume") {
419      state.conversation += 1;
420      state.lastMainAt = null;
421    } else {
422      await beat($, true).catch(() => undefined);
423    }
424    return next(e);
425  });
426
427  // A new conversation in a project with a waiting handoff note starts from it, once.
428  on("prompt.context", async ($, e, next) => {
429    const r = await next(e);
430    const text = await startFrom($).catch(() => null);
431    return text ? { ...r, blocks: [...r.blocks, { name: "optimaizrHandoff", text }] } : r;
432  });
433
434  // After compaction the conversation is new to every model's cache.
435  on("session.compact", async ($, e, next) => {
436    forget();
437    return next(e);
438  });
439
440  on("session.measure", async ($, e, next) => {
441    await take($, { context: e.context, rateLimits: e.rateLimits, cost: e.cost }).catch(
442      () => undefined,
443    );
444    return next(e);
445  });
446
447  on("turn.start", async ($, e, next) => {
448    await measure($).catch(() => undefined);
449    state.turn = {
450      startUsd: state.usage?.cost?.usd ?? 0,
451      startPct: fiveHour(state.usage?.rateLimits)?.percentUsed,
452      calls: 0,
453      held: 0,
454    };
455    return next(e);
456  });
457
458  on("turn.step", async function* ($, e, next) {
459    const req = {
460      cwd: state.root,
461      model: e.model,
462      subagent: e.agentId !== undefined,
463      ...(e.effort !== undefined ? { effort: e.effort } : {}),
464    };
465    const list = state.paused ? [] : await switches($).catch(() => []);
466    const sw = switchFor(list, req);
467    let model = sw && !state.refused.has(sw.to) ? sw : undefined;
468
469    // A conversation under way switches only once reloading it pays back soon.
470    // Subagents start with an empty cache, so they always switch.
471    const now = await $.clock.now();
472    const cold = state.lastMainAt !== null && now - state.lastMainAt > CACHE_TTL_MS;
473    if (model && e.agentId === undefined && !state.convo.loaded.has(model.to) && !cold) {
474      const context = state.usage?.context.tokens ?? 0;
475      const payback = paybackRequests(model, context, average(context));
476      if (payback > PAYBACK_REQUESTS) {
477        state.waiting = { o: model, reload: reloadCost(model, context) ?? 0, payback };
478        model = undefined;
479      } else {
480        state.waiting = null;
481      }
482    }
483    if (e.agentId === undefined) {
484      state.lastMainAt = now;
485      state.model = model?.to ?? e.model;
486    }
487    const ef = model ? undefined : effortFor(list, req);
488    const effort = ef && !state.refused.has(`effort:${ef.effort}`) ? ef : undefined;
489    const o = model ?? effort;
490    if (!o) {
491      const r = yield* next(e);
492      // A request that rewrote an expired cache would make every request look costly.
493      if (e.agentId === undefined && r.usage && !cold) learn(r.usage);
494      await counted($, r.usage);
495      return r;
496    }
497
498    // Stream the switched request through by hand, to know whether any of it
499    // arrived before deciding it failed.
500    const stream = next(
501      model ? { ...e, model: model.to } : { ...e, effort: o.effort as TurnStepInput["effort"] },
502    );
503    let arrived = false;
504    let result: TurnStepResult | undefined;
505    try {
506      for (;;) {
507        const step = await stream.next();
508        if (step.done) {
509          result = step.value;
510          break;
511        }
512        arrived = true;
513        yield step.value;
514      }
515    } catch (err) {
516      if (arrived || next.signal.aborted) throw err;
517    }
518    if (result && (arrived || !failed(result, next.signal.aborted))) {
519      state.applied.set(slotOf(o), o);
520      if (model && result.usage) {
521        // Only a warm main conversation has a cache to reload; a subagent, or a
522        // conversation idle past the cache's lifetime, writes it on either model.
523        const main = e.agentId === undefined;
524        const first = main && !cold && !state.convo.loaded.has(model.to);
525        if (main) state.convo.loaded.add(model.to);
526        const saved = savedBy(model, result.usage, first, e.agentId === undefined);
527        const was = costAt(model.from, result.usage, { warm: first, hour: main });
528        const is = costAt(model.to, result.usage, { hour: main });
529        if (was !== null && is !== null) {
530          state.switchedWas += was;
531          state.switchedIs += is;
532        }
533        const before = state.saved ?? 0;
534        if (saved !== null) state.saved = before + saved;
535        const milestone = crossed(SAVED_MILESTONES, before, state.saved ?? 0);
536        if (milestone !== null) {
537          void $.ui.toast(`optimAIzr: saved ${usd(state.saved ?? 0)} this session by switching`, {
538            timeoutMs: 6_000,
539          });
540        }
541        if (state.turn && saved !== null) state.turn.saved = (state.turn.saved ?? 0) + saved;
542      }
543      if (state.turn && model) state.turn.switched = model;
544      if (state.turn && !model) state.turn.effort = o.effort;
545      // Subagents and the main conversation are told apart: either can switch first.
546      const sub = e.agentId !== undefined;
547      const told = `${slotOf(o)}\u0000${sub ? "sub" : "main"}`;
548      if (!state.told.has(told)) {
549        state.told.add(told);
550        $.ui.log(switchNote(o, sub));
551        if (o.auto) {
552          void $.ui.toast(
553            `optimAIzr: ${o.effort ? `lowered effort to ${o.effort}` : `switched to ${modelLabel(o.to)}`} automatically · p or /optimaizr off undoes`,
554            { timeoutMs: 8_000 },
555          );
556        }
557      }
558      await counted($, result.usage);
559      return result;
560    }
561
562    // Refused before a word came back: send the request as it was, and leave
563    // this target alone for the rest of the session.
564    state.refused.add(model ? model.to : `effort:${o.effort}`);
565    void $.ui.toast(
566      model
567        ? `optimAIzr: ${modelLabel(model.to)} was refused, staying on ${modelLabel(e.model)}`
568        : `optimAIzr: ${o.effort} effort was refused, staying at ${String(e.effort)}`,
569    );
570    const r = yield* next(e);
571    await counted($);
572    return r;
573  });
574
575  on("tool.call", async ($, e, next) => {
576    const agent = e.agentId ?? "main";
577    const c = state.convo;
578
579    if (retryGuard && e.tool === "Bash") {
580      const key = commandKey(agent, e.command);
581      if ((c.fails.get(key) ?? 0) >= RETRY_LIMIT && !c.waived.has(key)) {
582        c.waived.add(key);
583        state.held += 1;
584        if (state.turn) state.turn.held += 1;
585        $.ui.log(
586          `Held a retry of \`${clip(e.command.trim())}\`: it failed twice in a row with nothing changed in between.`,
587        );
588        return {
589          deny:
590            "optimAIzr held this retry: the same command failed twice in a row and nothing has changed since. " +
591            "Change something first (the command, the code or the setup), or tell the user what is blocking you. " +
592            "If it must run unchanged, run it again and it will go through.",
593        };
594      }
595    }
596
597    const ran = await next(e);
598    const ok = ran.deny === undefined && ran.isError !== true;
599    if (e.tool === "Bash" && ran.isError === true) {
600      const key = commandKey(agent, e.command);
601      c.fails.set(key, (c.fails.get(key) ?? 0) + 1);
602    }
603    if (ok && MUTATING.has(e.tool)) {
604      c.fails.clear();
605      c.waived.clear();
606    }
607    return ran;
608  });
609
610  on("turn.complete", async ($, e, next) => {
611    const r = await next(e);
612    if (e.agentId !== undefined) return r;
613    const t = state.turn;
614    state.turn = null;
615    void $.ui.invalidate("ui.render");
616    if (!t || e.reason !== "answer") return r;
617
618    await measure($).catch(() => undefined);
619    longNote($);
620    const spent = (state.usage?.cost?.usd ?? 0) - t.startUsd;
621    state.turnCosts = [...state.turnCosts, Math.max(0, spent)].slice(-24);
622    if (!showTurnLine || spent < 0.005) return r;
623    const after = fiveHour(state.usage?.rateLimits)?.percentUsed;
624    const line = turnLine({
625      usd: spent,
626      calls: t.calls,
627      ...(t.switched
628        ? {
629            switched: {
630              from: modelLabel(t.switched.from),
631              to: modelLabel(t.switched.to),
632              saved: t.saved ?? null,
633            },
634          }
635        : {}),
636      ...(t.effort ? { effort: t.effort } : {}),
637      held: t.held,
638      ...(t.startPct !== undefined ? { before: t.startPct } : {}),
639      ...(after !== undefined ? { after } : {}),
640    });
641    return { ...r, text: line };
642  });
643
644  on("ui.render", { component: "Spinner" }, async ($, e, next) => {
645    const t = state.turn;
646    const extra = meterText({
647      usd: t ? (state.usage?.cost?.usd ?? 0) - t.startUsd : null,
648      five: fiveHour(state.usage?.rateLimits),
649      ...(t?.switched ? { model: modelLabel(t.switched.to) } : {}),
650      ...(t?.saved !== undefined ? { saved: t.saved } : {}),
651    });
652    if (!extra) return next(e);
653    return next({ ...e, props: { ...e.props, suffix: `${extra}${e.props.suffix}` } });
654  });
655
656  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
657    const u = state.usage;
658    if (e.props.hasSurvey || !u) return next(e);
659    const { Box, Text } = $.ui.resolve(e);
660    const five = fiveHour(u.rateLimits);
661    const waiting = waitingNote();
662    const cache = e.props.isWorking ? null : await cacheNow($);
663    const lines = [
664      ...switchLines(activeSwitches()),
665      ...(waiting ? [waiting] : []),
666      ...(cache ? [`${cache.label.padEnd(10)}${cache.text}`] : []),
667    ];
668    const wide = e.props.bodyColumns >= 72;
669
670    // Off a plan there is no window: the session's spend is the real bill.
671    if (!five) {
672      const spent = u.cost?.usd ?? 0;
673      if (spent < 0.01 && lines.length === 0) return next(e);
674      return (
675        <Box flexDirection="column">
676          <Box>
677            <Text color="blue" bold>
678              optimAIzr
679            </Text>
680            <Text dimColor>{`  ${usd(spent)} this session`}</Text>
681          </Box>
682          {lines[0] !== undefined && <Text dimColor>{lines[0]}</Text>}
683          {lines[1] !== undefined && <Text dimColor>{lines[1]}</Text>}
684          {lines[2] !== undefined && <Text dimColor>{lines[2]}</Text>}
685        </Box>
686      );
687    }
688
689    const [filled, empty] = bar(five.percentUsed, 16);
690    const week = sevenDay(u.rateLimits);
691    const note = wide ? windowNote(five, state.window, await $.clock.now()) : "";
692    const weekNote = week && week.percentUsed >= 75 ? ` · 7d ${percent(week.percentUsed)}` : "";
693    return (
694      <Box flexDirection="column">
695        <Box>
696          <Text color="blue" bold>
697            optimAIzr
698          </Text>
699          <Text>{"  "}</Text>
700          {wide && <Text color={meterColor(five.percentUsed)}>{filled}</Text>}
701          {wide && <Text dimColor>{`${empty}  `}</Text>}
702          <Text color={meterColor(five.percentUsed)} bold>
703            {percent(five.percentUsed)}
704          </Text>
705          <Text>{" of 5h"}</Text>
706          <Text dimColor>{`${note}${weekNote}`}</Text>
707        </Box>
708        {lines[0] !== undefined && <Text dimColor>{lines[0]}</Text>}
709        {lines[1] !== undefined && <Text dimColor>{lines[1]}</Text>}
710        {lines[2] !== undefined && <Text dimColor>{lines[2]}</Text>}
711      </Box>
712    );
713  });
714
715  on("command.run", { command: "optimaizr" }, async ($, e) => {
716    const list = await switches($).catch(() => []);
717    const here = list.filter((o) => inProject(state.root, o.project));
718    const arg = e.args.trim().toLowerCase();
719    if (arg === "off" || arg === "on") {
720      state.paused = arg === "off";
721      void $.ui.invalidate("ui.render");
722      return { text: pauseText(here, state.paused) };
723    }
724    // "pane" was its first name; it still works.
725    if (arg === "hud" || arg === "pane") {
726      await $.ui.open({ id: PANE, title: "optimAIzr HUD", focus: true });
727      return { text: "Opened the optimAIzr HUD. Esc closes it." };
728    }
729    if (arg === "handoff") return { text: await handoff($) };
730    if (arg) {
731      return {
732        text: "Use /optimaizr, /optimaizr hud, /optimaizr handoff, /optimaizr off or /optimaizr on.",
733      };
734    }
735
736    await measure($).catch(() => undefined);
737    const u = state.usage;
738    const waiting = waitingNote();
739    const cache = await cacheNow($);
740    return {
741      text: summary({
742        ...(u?.cost ? { usd: u.cost.usd } : {}),
743        requests: state.requests,
744        startedAt: state.startedAt,
745        limits: u?.rateLimits ?? [],
746        ...(state.window ? { window: state.window } : {}),
747        now: await $.clock.now(),
748        switches: here,
749        paused: state.paused,
750        ...(state.saved !== undefined ? { saved: state.saved } : {}),
751        held: state.held,
752        turns: state.turnCosts,
753        ...(waiting ? { note: waiting } : {}),
754        ...(cache ? { cache } : {}),
755      }),
756    };
757  });
758
759  // The session at a glance: /optimaizr hud.
760  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
761    const { Box, Text, Button } = $.ui.resolve(e);
762    const u = state.usage;
763    const five = fiveHour(u?.rateLimits);
764    const week = sevenDay(u?.rateLimits);
765    const now = await $.clock.now();
766    const cols = e.props.bodyColumns;
767    const width = Math.max(8, Math.min(24, cols - 22));
768
769    const status = hudStatus();
770    const saved = state.saved !== undefined && state.saved > 0 ? state.saved : 0;
771    const cheaper =
772      state.switchedWas > 0 ? Math.round((1 - state.switchedIs / state.switchedWas) * 100) : null;
773    const spent = u?.cost ? u.cost.usd : null;
774    // The rate covers what this mod has watched, from when it loaded.
775    const hours = (now - state.startedAt) / 3_600_000;
776    const rate =
777      spent !== null && state.startUsd !== null && hours >= 1 / 30
778        ? (spent - state.startUsd) / hours
779        : null;
780    const t = state.tokens;
781    const ofWindow =
782      five && spent !== null && state.startUsd !== null && state.startPct !== null
783        ? windowShare(saved, {
784            usd: spent - state.startUsd,
785            pct: five.percentUsed - state.startPct,
786          })
787        : null;
788    const cacheHits = t.input + t.read + t.write > 0 ? t.read / (t.input + t.read + t.write) : null;
789
790    const gauge = (label: string, pct: number, note: string) => (
791      <Box flexDirection="column">
792        <Box>
793          <Text dimColor>{`${label}  `}</Text>
794          <Text color={meterColor(pct)}>{bar(pct, width)[0]}</Text>
795          <Text dimColor>{bar(pct, width)[1]}</Text>
796          <Text color={meterColor(pct)} bold>{`  ${percent(pct)}`}</Text>
797        </Box>
798        {note && <Text dimColor>{`    ${note}`}</Text>}
799      </Box>
800    );
801    const turns = state.turnCosts;
802    const top = Math.max(...turns, 0);
803    const priciest = turns.indexOf(top) + 1;
804    const lines = switchLines(activeSwitches());
805    const waiting = waitingNote();
806    const switchable = activeSwitches().length > 0 || waiting !== null;
807    const cache = await cacheNow($);
808
809    return (
810      <Box flexDirection="column" gap={1}>
811        <Box>
812          <Text color="blue" bold>
813            optimAIzr
814          </Text>
815          <Text color={status.color} bold>{`   ● ${status.text}`}</Text>
816        </Box>
817
818        <Box flexDirection="column">
819          {state.switchedWas === 0 ? (
820            <Text dimColor>no switch yet · press Y in optimaizr live</Text>
821          ) : (state.saved ?? 0) < 0 ? (
822            <Text color="yellow">{`reload ${usd(-(state.saved ?? 0))} not won back yet`}</Text>
823          ) : (
824            <Box>
825              <Text color="green" bold>{`saved ${usd(saved)}`}</Text>
826              {cheaper !== null && cheaper > 0 && (
827                <Text color="green">{`  ${cheaper}% cheaper`}</Text>
828              )}
829              {ofWindow !== null && (
830                <Text color="green">{`  ≈${share(ofWindow)} of your 5h window`}</Text>
831              )}
832            </Box>
833          )}
834          <Text dimColor>
835            {[
836              spent !== null ? `spent ${usd(spent)}` : null,
837              rate !== null ? `${usd(rate)}/h` : null,
838              `${state.requests} requests`,
839              cacheHits !== null ? `cache ${percent(cacheHits * 100)}` : null,
840            ]
841              .filter(Boolean)
842              .join(" · ")}
843          </Text>
844        </Box>
845
846        {five && gauge("5h", five.percentUsed, paceNote(five, now))}
847        {week && gauge("7d", week.percentUsed, "")}
848
849        {turns.length > 1 && (
850          <Box flexDirection="column">
851            <Box>
852              <Text dimColor>{"turns  "}</Text>
853              {turns.map((v) => (
854                <Text
855                  color={v / (top || 1) > 0.66 ? "red" : v / (top || 1) > 0.33 ? "yellow" : "green"}
856                >
857                  {sparkline([v, top]).charAt(0)}
858                </Text>
859              ))}
860            </Box>
861            <Text dimColor>{`    priciest #${priciest} ${usd(top)} · last ${turns.length}`}</Text>
862          </Box>
863        )}
864
865        {lines[0] !== undefined && <Text>{lines[0]}</Text>}
866        {lines[1] !== undefined && <Text>{lines[1]}</Text>}
867        {waiting && <Text color="yellow">{waiting}</Text>}
868        {cache && <Text color="yellow">{`${cache.label.padEnd(10)}${cache.text}`}</Text>}
869        {state.held > 0 && (
870          <Text
871            dimColor
872          >{`guard  ${state.held} ${state.held === 1 ? "retry" : "retries"} held`}</Text>
873        )}
874
875        <Box gap={2}>
876          {switchable && (
877            <Button
878              key="pause"
879              label={state.paused ? "resume switch" : "pause switch"}
880              hotkey="p"
881              onPress={() => {
882                state.paused = !state.paused;
883                void $.ui.invalidate("ui.render");
884              }}
885            />
886          )}
887          <Text dimColor>Esc closes</Text>
888        </Box>
889      </Box>
890    );
891  });
892};
893
894/** Once a conversation passes LONG_CONTEXT, say what each request now re-reads. */
895function longNote($: Api): void {
896  const context = state.usage?.context.tokens ?? 0;
897  const cost = carryCost(state.model, context);
898  if (state.convo.long || context < LONG_CONTEXT || !cost) return;
899  state.convo.long = true;
900  void $.ui.toast(
901    `optimAIzr: this conversation is past ${kTokens(LONG_CONTEXT)}. Each request re-reads all ${kTokens(context)} (${usd(cost.read)}). At a natural break, /optimaizr handoff, then /clear.`,
902    { timeoutMs: 10_000 },
903  );
904}
905
906/** The pill at the top of the HUD: what the mod is doing right now. */
907function hudStatus(): { text: string; color: string } {
908  if (state.paused) return { text: "paused", color: "yellow" };
909  if (waitingNote()) return { text: "waiting", color: "yellow" };
910  const model = activeSwitches().find((o) => !o.effort);
911  if (model) {
912    return { text: `on ${modelLabel(model.to)}${model.auto ? " (auto)" : ""}`, color: "green" };
913  }
914  const effort = activeSwitches().find((o) => o.effort);
915  if (effort) return { text: `${effort.effort} effort`, color: "green" };
916  return { text: "watching", color: "blue" };
917}
918
919/** The 5-hour window's outlook: when it runs out at this pace, or that it lasts. */
920function paceNote(
921  five: { resetsAt?: string; percentUsed: number; kind: string },
922  now: number,
923): string {
924  const note = windowNote(five, state.window, now).replace(/^ · /, "");
925  return note.startsWith("lasts") ? `${note} ✓` : note;
926}
927
928/** Why a switch for this conversation is waiting, if one is. */
929function waitingNote(): string | null {
930  const w = state.waiting;
931  if (!w || state.paused || state.convo.loaded.has(w.o.to)) return null;
932  const back = Number.isFinite(w.payback)
933    ? `pays back in ~${Math.ceil(w.payback)} requests`
934    : "would not pay back";
935  return `waiting   ${describeSwitch(w.o)} · reload ${usd(w.reload)} ${back} · subagents switch now`;
936}
937
938/** Switches that moved a request here and are still in overrides.json. */
939function activeSwitches(): Override[] {
940  return [...state.applied.values()].filter((a) =>
941    state.overrides.some((o) => slotOf(o) === slotOf(a) && o.to === a.to),
942  );
943}
944
945/** The band's lines under the window: the model switch, then the effort switch. */
946function switchLines(applied: readonly Override[]): string[] {
947  const back = state.paused ? "/optimaizr on to switch again" : "harder task? /optimaizr off";
948  const saved =
949    !state.paused && state.saved !== undefined && state.saved >= 0.005
950      ? ` · saved ${usd(state.saved)}`
951      : "";
952  const lines: string[] = [];
953  // While the main conversation waits, the waiting line says what subagents do.
954  const model = waitingNote() ? undefined : applied.find((o) => !o.effort);
955  if (model) {
956    lines.push(
957      `${state.paused ? "paused  " : "switched"}  ${describeSwitch(model)}${model.auto ? " (auto)" : ""}${saved} · ${back}`,
958    );
959  }
960  const effort = applied.find((o) => o.effort);
961  if (effort) {
962    lines.push(`${state.paused ? "paused  " : "effort  "}  ${describeSwitch(effort)} · ${back}`);
963  }
964  return lines;
965}
966
967/** The line left in the conversation the first time a switch moves a request. */
968function switchNote(o: Override, sub: boolean): string {
969  const who = sub || o.subagent === true ? "this session's subagents" : "this session";
970  const how = o.auto ? `${o.rule}, automatically` : o.rule;
971  if (o.effort) {
972    return (
973      `Lowered ${who} to ${o.effort} effort on ${modelLabel(o.from)} (${how}). ` +
974      `Harder task? /optimaizr off goes back to its own effort.`
975    );
976  }
977  return (
978    `Switched ${who} from ${modelLabel(o.from)} to ${modelLabel(o.to)} (${how}). ` +
979    `Harder task? /optimaizr off goes back to ${modelLabel(o.from)}.`
980  );
981}
982
983function pauseText(here: readonly Override[], paused: boolean): string {
984  const o = here.find((x) => !x.effort) ?? here[0];
985  if (!o) return "No switch is active in this project.";
986  if (o.effort) {
987    return paused
988      ? `Switches are off for this session: requests use their own effort again. ` +
989          `/optimaizr on lowers it to ${o.effort} again; optimaizr undo ${o.rule} removes it everywhere.`
990      : `Switches are on again for this session: requests use ${o.effort} effort.`;
991  }
992  return paused
993    ? `Switches are off for this session: requests go to ${modelLabel(o.from)} again. ` +
994        `/optimaizr on switches back to ${modelLabel(o.to)}; ` +
995        `optimaizr undo ${o.rule} removes it everywhere.`
996    : `Switches are on again for this session: requests go to ${modelLabel(o.to)}.`;
997}
998
hooks/meter.ts 575 lines
1// Pure helpers for register.tsx. Nothing here touches `$`, so the figures the
2// band, the spinner and /optimaizr show can be tested on their own.
3
4import { PRICES, type Price } from "./prices.ts";
5
6export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string };
7
8/** One reading of the 5-hour window: when, and how full. */
9export type Sample = { t: number; pct: number };
10
11/** The readings of one 5-hour window, kept in $.store so every session shares them. */
12export type Window = { resetsAt: string; samples: Sample[] };
13
14/** An entry of ~/.optimaizr/overrides.json, as `optimaizr live` writes it. */
15export type Override = {
16  rule: string;
17  source?: string;
18  project: string;
19  subagent?: boolean;
20  from: string;
21  to: string;
22  /** An effort switch: the same model, asked to think less. */
23  effort?: string;
24  /** Applied by `optimaizr live --auto`, with no one pressing Y. */
25  auto?: boolean;
26};
27
28/** A request's token counts, as the API reports them. */
29export type Tokens = {
30  input_tokens: number;
31  output_tokens: number;
32  cache_read_input_tokens: number;
33  cache_creation_input_tokens: number;
34};
35
36const MINUTE = 60_000;
37const HOUR = 60 * MINUTE;
38
39export const fiveHour = (limits: readonly RateLimit[] | undefined) =>
40  limits?.find((l) => l.kind === "five_hour");
41
42export const sevenDay = (limits: readonly RateLimit[] | undefined) =>
43  limits?.find((l) => l.kind === "seven_day");
44
45/**
46 * The meters as the session file carries them, so `optimaizr profile` and
47 * `optimaizr live` can show Claude Code's real windows. Undefined when neither
48 * meter is reported (an API-key session has none).
49 */
50export function windowsOf(limits: readonly RateLimit[] | undefined, at: number) {
51  const pick = (l: RateLimit | undefined) =>
52    l ? { percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) } : undefined;
53  const five = pick(fiveHour(limits));
54  const seven = pick(sevenDay(limits));
55  if (!five && !seven) return undefined;
56  return {
57    at: new Date(at).toISOString(),
58    ...(five ? { fiveHour: five } : {}),
59    ...(seven ? { sevenDay: seven } : {}),
60  };
61}
62
63/** `claude-opus-5-5[1m]` and `claude-opus-5-5-20260915` are `claude-opus-5-5`. */
64export function baseModel(id: string): string {
65  return id
66    .toLowerCase()
67    .replace(/\[[^\]]*\]$/, "")
68    .replace(/-\d{8}$/, "");
69}
70
71/** `claude-sonnet-5-5` is `Sonnet 5.5`; an id it can't read is shown as given. */
72export function modelLabel(id: string): string {
73  const m = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?$/.exec(baseModel(id));
74  if (!m) return id;
75  const family = m[1]!;
76  return `${family[0]!.toUpperCase()}${family.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ""}`;
77}
78
79export function usd(n: number): string {
80  return `$${n.toFixed(2)}`;
81}
82
83export function percent(n: number): string {
84  return `${Math.round(n)}%`;
85}
86
87/** `45m`, `2h 05m`. */
88export function duration(ms: number): string {
89  const m = Math.max(1, Math.round(ms / MINUTE));
90  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
91}
92
93/** Local wall-clock time, `01:00`. */
94export function clock(iso: string): string {
95  const d = new Date(iso);
96  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
97}
98
99/**
100 * A saving as a share of the 5-hour window, from how far the window moved
101 * against what was spent meanwhile. Null until it has moved a whole point;
102 * other sessions on the account move it too, so it is an estimate.
103 */
104export function windowShare(saved: number, spent: { usd: number; pct: number }): number | null {
105  if (spent.pct < 1 || spent.usd <= 0 || saved <= 0) return null;
106  return saved / (spent.usd / spent.pct);
107}
108
109/** `0.4%`, `2.1%`, `12%`. */
110export function share(pct: number): string {
111  return pct < 10 ? `${pct.toFixed(1)}%` : `${Math.round(pct)}%`;
112}
113
114/** The meter's colour as the window fills: calm, then a warning, then urgent. */
115export function meterColor(pct: number): "green" | "yellow" | "red" {
116  return pct < 60 ? "green" : pct < 85 ? "yellow" : "red";
117}
118
119/** A sparkline of turn costs, one cell each, scaled to the largest. */
120export function sparkline(values: readonly number[]): string {
121  const cells = "▁▂▃▄▅▆▇█";
122  const top = Math.max(...values, 0);
123  return values.map((v) => cells[top > 0 ? Math.min(7, Math.round((v / top) * 7)) : 0]).join("");
124}
125
126/** The window levels that earn a toast, and the session savings that do. */
127export const WINDOW_ALERTS = [80, 95];
128export const SAVED_MILESTONES = [0.5, 1, 2, 5, 10, 20, 50];
129
130/** The highest threshold crossed going from `was` to `now`, if any. */
131export function crossed(thresholds: readonly number[], was: number, now: number): number | null {
132  const hit = thresholds.filter((t) => was < t && now >= t);
133  return hit.length > 0 ? hit[hit.length - 1]! : null;
134}
135
136/** The filled and empty halves of a meter `width` cells wide. */
137export function bar(pct: number, width: number): [string, string] {
138  const n = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width);
139  return ["█".repeat(n), "░".repeat(width - n)];
140}
141
142export function projectName(p: string): string {
143  return p.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? p;
144}
145
146/** `shop-api subagents: Opus 5.5 → Sonnet 5.5`, or `shop-api: Opus 5.5 at low effort`. */
147export function describeSwitch(o: Override): string {
148  const who = o.subagent === true ? " subagents" : o.subagent === false ? " main" : "";
149  const what = o.effort
150    ? `${modelLabel(o.from)} at ${o.effort} effort`
151    : `${modelLabel(o.from)} → ${modelLabel(o.to)}`;
152  return `${projectName(o.project)}${who}: ${what}`;
153}
154
155function isOverride(v: unknown): v is Override {
156  const o = v as Override;
157  return (
158    typeof o === "object" &&
159    o !== null &&
160    o.source === "claude-code" &&
161    typeof o.rule === "string" &&
162    typeof o.project === "string" &&
163    typeof o.from === "string" &&
164    typeof o.to === "string" &&
165    (o.subagent === undefined || typeof o.subagent === "boolean") &&
166    (o.effort === undefined || EFFORTS.includes(o.effort)) &&
167    (o.auto === undefined || typeof o.auto === "boolean")
168  );
169}
170
171/** Claude Code's entries of overrides.json; anything unreadable is no switch at all. */
172export function parseOverrides(text: string): Override[] {
173  try {
174    const file = JSON.parse(text) as { overrides?: unknown };
175    return Array.isArray(file?.overrides) ? file.overrides.filter(isOverride) : [];
176  } catch {
177    return [];
178  }
179}
180
181const slash = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "");
182
183/** Is `cwd` the project, or a folder inside it? */
184export function inProject(cwd: string, project: string): boolean {
185  const c = slash(cwd);
186  const p = slash(project);
187  return c === p || c.startsWith(`${p}/`);
188}
189
190const covers = (o: Override, req: { cwd: string; model: string; subagent: boolean }) =>
191  inProject(req.cwd, o.project) &&
192  (o.subagent === undefined || o.subagent === req.subagent) &&
193  baseModel(o.from) === baseModel(req.model);
194
195/** The model switch for one request: its project, agent and model. */
196export function switchFor(
197  list: readonly Override[],
198  req: { cwd: string; model: string; subagent: boolean },
199): Override | undefined {
200  return list.find((o) => !o.effort && covers(o, req) && baseModel(o.to) !== baseModel(req.model));
201}
202
203/** Lowest first. A number, or a level not listed, is never lowered. */
204const EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
205
206/** The effort switch for one request, only when it asks for less than the request does. */
207export function effortFor(
208  list: readonly Override[],
209  req: { cwd: string; model: string; subagent: boolean; effort?: string | number },
210): Override | undefined {
211  const now = typeof req.effort === "string" ? EFFORTS.indexOf(req.effort) : -1;
212  return list.find(
213    (o) => o.effort !== undefined && covers(o, req) && EFFORTS.indexOf(o.effort) < now,
214  );
215}
216
217const priceOf = (model: string): Price | undefined => PRICES[baseModel(model)];
218
219/**
220 * What a request's tokens cost at a model's rates. `warm`: its cache writes would
221 * have been reads. `hour`: the main conversation caches for an hour, at 2x input;
222 * subagents for 5 minutes, at 1.25x, as the engine prices them.
223 */
224export function costAt(
225  model: string,
226  t: Tokens,
227  opts: { warm?: boolean; hour?: boolean } = {},
228): number | null {
229  const p = priceOf(model);
230  if (!p) return null;
231  const write = opts.warm ? p.cacheRead : opts.hour ? p.cacheWrite1h : p.cacheWrite;
232  return (
233    (t.input_tokens * p.input +
234      t.output_tokens * p.output +
235      t.cache_read_input_tokens * p.cacheRead +
236      t.cache_creation_input_tokens * write) /
237    1_000_000
238  );
239}
240
241/**
242 * What a switched request saved: the same tokens at the original model's rates,
243 * less what they cost on the new one. The first request on the new model writes
244 * the conversation to its cache, where the original model would have read it,
245 * so that one is priced warm and can come out negative.
246 */
247/** What reloading a conversation of `contextTokens` into the new model's cache costs, over reading it on the old one. */
248export function reloadCost(o: Override, contextTokens: number): number | null {
249  const from = priceOf(o.from);
250  const to = priceOf(o.to);
251  if (!from || !to) return null;
252  return (contextTokens * (to.cacheWrite1h - from.cacheRead)) / 1_000_000;
253}
254
255// Switching a conversation under way waits until the reload is won back within this many requests.
256export const PAYBACK_REQUESTS = 10;
257
258/**
259 * Requests until a mid-conversation switch pays for its reload, from this
260 * session's average request. Infinity when a request saves nothing.
261 */
262export function paybackRequests(o: Override, contextTokens: number, avg: Tokens): number {
263  const reload = reloadCost(o, contextTokens);
264  const was = costAt(o.from, avg, { hour: true });
265  const is = costAt(o.to, avg, { hour: true });
266  if (reload === null || was === null || is === null) return Infinity;
267  if (reload <= 0) return 0;
268  return was > is ? reload / (was - is) : Infinity;
269}
270
271export function savedBy(o: Override, t: Tokens, first: boolean, hour = true): number | null {
272  const was = costAt(o.from, t, { warm: first, hour });
273  const is = costAt(o.to, t, { hour });
274  return was === null || is === null ? null : was - is;
275}
276
277/** `340K`. */
278export function kTokens(n: number): string {
279  return `${Math.round(n / 1_000)}K`;
280}
281
282// Below this, writing the conversation again costs about what a fresh start does.
283export const COLD_MIN_CONTEXT = 50_000;
284// Past this, each request re-reads far more than the work in front of it needs.
285export const LONG_CONTEXT = 200_000;
286// The band counts down this long before the cache expires.
287export const COUNTDOWN_MS = 15 * MINUTE;
288// One toast this close to expiry, when coming back would cost at least EXPIRY_TOAST_USD.
289export const EXPIRY_TOAST_MS = 5 * MINUTE;
290export const EXPIRY_TOAST_USD = 0.5;
291
292/**
293 * What carrying the conversation costs on one request: read from a warm cache,
294 * or written to it again at the 1-hour rate once it expired.
295 */
296export function carryCost(
297  model: string,
298  contextTokens: number,
299): { read: number; rewrite: number } | null {
300  const p = priceOf(model);
301  if (!p) return null;
302  return {
303    read: (contextTokens * p.cacheRead) / 1_000_000,
304    rewrite: (contextTokens * p.cacheWrite1h) / 1_000_000,
305  };
306}
307
308/**
309 * What to say about the conversation's cache while the prompt waits: a
310 * countdown before it expires, what coming back costs after, or what each
311 * request re-reads once the conversation is long. Null when none applies.
312 */
313export function cacheNote(c: {
314  model: string;
315  context: number;
316  idleMs: number;
317  ttlMs: number;
318}): { label: "cache" | "context"; text: string } | null {
319  if (c.context < COLD_MIN_CONTEXT) return null;
320  const cost = carryCost(c.model, c.context);
321  if (!cost) return null;
322  const left = c.ttlMs - c.idleMs;
323  const size = kTokens(c.context);
324  if (left <= 0) {
325    return {
326      label: "cache",
327      text: `expired · the next message writes ${size} again (${usd(cost.rewrite)})`,
328    };
329  }
330  if (left <= COUNTDOWN_MS) {
331    return {
332      label: "cache",
333      text: `warm ${duration(left)} more, then ${size} is written again (${usd(cost.rewrite)}) · leaving? /optimaizr handoff`,
334    };
335  }
336  if (c.context >= LONG_CONTEXT) {
337    return {
338      label: "context",
339      text: `${size} · each request re-reads it (${usd(cost.read)}) · fresh start: /optimaizr handoff`,
340    };
341  }
342  return null;
343}
344
345// A handoff note is used once, by a conversation started within this long.
346export const HANDOFF_TTL_MS = 12 * HOUR;
347
348/** What Claude is asked for: a note a fresh conversation can start from. */
349export const HANDOFF_PROMPT =
350  "Write a handoff note so a fresh conversation can carry on this work without this one. " +
351  "Plain text, under 250 words, in four short parts: what changed (files and why), " +
352  "what was decided, what is still open, and what to check first. Name exact paths, " +
353  "commands and errors. No preamble, and no tool calls.";
354
355export type Handoff = {
356  version: 1;
357  root: string;
358  /** The session and conversation it was written in, which never read it back. */
359  session: string;
360  conversation: number;
361  at: string;
362  text: string;
363  usedAt?: string;
364};
365
366export function isHandoff(v: unknown): v is Handoff {
367  const h = v as Handoff;
368  return (
369    typeof h === "object" &&
370    h !== null &&
371    h.version === 1 &&
372    typeof h.root === "string" &&
373    typeof h.session === "string" &&
374    typeof h.conversation === "number" &&
375    typeof h.at === "string" &&
376    typeof h.text === "string"
377  );
378}
379
380/** One note per project: `/work/shop-api` keeps `work-shop-api.json`. */
381export function handoffFile(dir: string, root: string): string {
382  const key =
383    slash(root)
384      .replace(/[^A-Za-z0-9._-]+/g, "-")
385      .replace(/^-+|-+$/g, "") || "root";
386  return `${dir}/mod/handoffs/${key}.json`;
387}
388
389/**
390 * The note a new conversation should start from, if one is waiting: same
391 * project, unused, recent, and written in another conversation.
392 */
393export function handoffFor(
394  v: unknown,
395  at: { root: string; session: string; conversation: number; now: number },
396): Handoff | null {
397  if (!isHandoff(v) || v.usedAt || slash(v.root) !== slash(at.root)) return null;
398  if (v.session === at.session && v.conversation === at.conversation) return null;
399  const age = at.now - Date.parse(v.at);
400  return age >= 0 && age <= HANDOFF_TTL_MS ? v : null;
401}
402
403/** The context block the next conversation reads. */
404export function handoffBlock(h: Handoff): string {
405  return (
406    `A handoff note from the previous conversation in this project, written by Claude at ` +
407    `${clock(h.at)} and saved by optimAIzr. Check it against the code before relying on it.\n\n` +
408    h.text.trim()
409  );
410}
411
412export function isWindow(v: unknown): v is Window {
413  const w = v as Window;
414  return (
415    typeof w === "object" &&
416    w !== null &&
417    typeof w.resetsAt === "string" &&
418    Array.isArray(w.samples)
419  );
420}
421
422/** Add a reading. A new window, or one that went down, starts the history over. */
423export function record(w: Window | undefined, limit: RateLimit, t: number): Window {
424  const resetsAt = limit.resetsAt ?? "";
425  const pct = limit.percentUsed;
426  const last = w?.samples.at(-1);
427  if (!w || w.resetsAt !== resetsAt || (last && pct < last.pct)) {
428    return { resetsAt, samples: [{ t, pct }] };
429  }
430  if (last && last.pct === pct) return w;
431  return { resetsAt, samples: [...w.samples, { t, pct }].slice(-120) };
432}
433
434/**
435 * When the window runs out at the pace of the last hour, as epoch ms. Null
436 * until there are 10 minutes and one point of use to go on.
437 */
438export function runsOutAt(w: Window): number | null {
439  const last = w.samples.at(-1);
440  const base = w.samples.find((s) => last && s.t >= last.t - HOUR);
441  if (!last || !base) return null;
442  const span = last.t - base.t;
443  const used = last.pct - base.pct;
444  if (span < 10 * MINUTE || used < 1) return null;
445  return last.t + ((100 - last.pct) / used) * span;
446}
447
448/** What follows the percent in the band: the pace, then the reset. */
449export function windowNote(five: RateLimit, w: Window | undefined, now: number): string {
450  const resets = five.resetsAt ? clock(five.resetsAt) : null;
451  const end = w && w.resetsAt === (five.resetsAt ?? "") ? runsOutAt(w) : null;
452  const resetAt = five.resetsAt ? Date.parse(five.resetsAt) : null;
453  if (end !== null && resetAt !== null && end >= resetAt) return ` · lasts to the ${resets} reset`;
454  const pace = end === null ? "" : ` · ~${duration(Math.max(0, end - now))} left at this pace`;
455  return `${pace}${resets ? ` · resets ${resets}` : ""}`;
456}
457
458/** What the spinner shows after its word: the switched model, the turn so far, the window. */
459export function meterText(m: {
460  usd: number | null;
461  five?: RateLimit;
462  model?: string;
463  saved?: number;
464}): string {
465  const parts: string[] = [];
466  if (m.model) parts.push(m.model);
467  if (m.usd !== null && m.usd >= 0.005) parts.push(usd(m.usd));
468  if (m.saved !== undefined && m.saved >= 0.005) parts.push(`saved ${usd(m.saved)}`);
469  if (m.five) parts.push(`${percent(m.five.percentUsed)} of 5h`);
470  return parts.length > 0 ? ` · ${parts.join(" · ")}` : "";
471}
472
473const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
474
475/**
476 * The line under an answer. Claude Code puts the plugin's name in front of it.
477 * A switched turn leads with what it saved, so the difference reads first.
478 */
479export function turnLine(t: {
480  usd: number;
481  calls: number;
482  /** The model switch this turn ran under: labels, and what it saved (null: unpriced). */
483  switched?: { from: string; to: string; saved: number | null };
484  effort?: string;
485  held?: number;
486  before?: number;
487  after?: number;
488}): string {
489  const parts: string[] = [];
490  const s = t.switched;
491  if (s && s.saved !== null && s.saved >= 0.005) {
492    parts.push(`saved ${usd(s.saved)} vs ${s.from}`, `this turn ${usd(t.usd)} on ${s.to}`);
493  } else if (s) {
494    parts.push(`this turn ${usd(t.usd)} on ${s.to}`);
495  } else {
496    parts.push(`this turn ${usd(t.usd)}${t.effort ? ` at ${t.effort} effort` : ""}`);
497  }
498  parts.push(plural(t.calls, "request"));
499  if (s && s.saved !== null && s.saved <= -0.005) {
500    parts.push(`${usd(-s.saved)} more than ${s.from} once, to load the conversation`);
501  }
502  if (t.held) parts.push(`${plural(t.held, "retry", "retries")} held`);
503  if (t.before !== undefined && t.after !== undefined) {
504    parts.push(`5h ${percent(t.before)} → ${percent(t.after)}`);
505  }
506  return parts.join(" · ");
507}
508
509/** What /optimaizr prints. */
510export function summary(s: {
511  usd?: number;
512  requests: number;
513  startedAt: number;
514  limits: readonly RateLimit[];
515  window?: Window;
516  now: number;
517  switches: readonly Override[];
518  paused?: boolean;
519  /** Net saving of this session's switched requests, when any were priced. */
520  saved?: number;
521  held?: number;
522  /** What each recent turn cost, oldest first. */
523  turns?: readonly number[];
524  /** Why a switch for this conversation is waiting, when one is. */
525  note?: string;
526  /** The conversation's cache, when there is something to say about it. */
527  cache?: { label: string; text: string };
528}): string {
529  const rows: Array<[string, string]> = [];
530  const onPlan = s.limits.length > 0;
531  const spent = s.usd === undefined ? "not reported" : usd(s.usd);
532  rows.push([
533    "Spend",
534    `${spent}${onPlan ? " at API rates" : ""} over ${plural(s.requests, "request")} since ${clock(new Date(s.startedAt).toISOString())}`,
535  ]);
536  if (s.saved !== undefined && s.saved < 0) {
537    rows.push([
538      "Saved",
539      `not yet: reloading the conversation cost ${usd(-s.saved)} more than the switch has saved so far`,
540    ]);
541  } else if (s.saved !== undefined) {
542    rows.push([
543      "Saved",
544      `${usd(s.saved)} by switching models: the same tokens at the original model's rates, less what they cost`,
545    ]);
546  }
547  if (s.held) rows.push(["Guard", `${plural(s.held, "retry", "retries")} held`]);
548  if (s.turns && s.turns.length > 1) {
549    rows.push([
550      "Turns",
551      `${sparkline(s.turns)}  last ${s.turns.length}, up to ${usd(Math.max(...s.turns))}`,
552    ]);
553  }
554  const five = fiveHour(s.limits);
555  if (five)
556    rows.push(["5h window", `${percent(five.percentUsed)}${windowNote(five, s.window, s.now)}`]);
557  const week = sevenDay(s.limits);
558  if (week) {
559    const resets = week.resetsAt
560      ? ` · resets ${new Date(week.resetsAt).toDateString().slice(0, 3)} ${clock(week.resetsAt)}`
561      : "";
562    rows.push(["7d window", `${percent(week.percentUsed)}${resets}`]);
563  }
564  if (s.cache) rows.push([s.cache.label === "cache" ? "Cache" : "Context", s.cache.text]);
565  for (const [i, o] of s.switches.entries()) {
566    const how = s.paused
567      ? "off in this session: /optimaizr on"
568      : `/optimaizr off here, optimaizr undo ${o.rule} everywhere`;
569    rows.push([i === 0 ? "Switch" : "", `${describeSwitch(o)} · ${how}`]);
570  }
571  if (s.switches.length === 0) rows.push(["Switch", "none: accept one with Y in optimaizr live"]);
572  if (s.note) rows.push(["", s.note.replace(/^waiting\s+/, "waiting: ")]);
573  return rows.map(([k, v]) => `${k.padEnd(11)}${v}`).join("\n");
574}
575
hooks/prices.ts 120 lines
1// Generated by apps/cli/scripts/mod-prices.mjs from the engine's catalogue.
2// Don't edit by hand: run `npm run mod:prices` in apps/cli.
3
4export type Price = {
5  label: string;
6  input: number;
7  output: number;
8  cacheRead: number;
9  cacheWrite: number;
10  cacheWrite1h: number;
11};
12
13/** USD per million tokens, the rates in force when this file was written. */
14export const PRICES: Record<string, Price> = {
15  "claude-fable-5-1": {
16    label: "Fable 5.1",
17    input: 10,
18    output: 50,
19    cacheRead: 0.25,
20    cacheWrite: 12.5,
21    cacheWrite1h: 20,
22  },
23  "claude-mythos-5-1": {
24    label: "Mythos 5.1",
25    input: 10,
26    output: 50,
27    cacheRead: 0.25,
28    cacheWrite: 12.5,
29    cacheWrite1h: 20,
30  },
31  "claude-fable-5": {
32    label: "Fable 5",
33    input: 10,
34    output: 50,
35    cacheRead: 1,
36    cacheWrite: 12.5,
37    cacheWrite1h: 20,
38  },
39  "claude-mythos-5": {
40    label: "Mythos 5",
41    input: 10,
42    output: 50,
43    cacheRead: 1,
44    cacheWrite: 12.5,
45    cacheWrite1h: 20,
46  },
47  "claude-opus-5-5": {
48    label: "Opus 5.5",
49    input: 4,
50    output: 20,
51    cacheRead: 0.2,
52    cacheWrite: 5,
53    cacheWrite1h: 8,
54  },
55  "claude-opus-5": {
56    label: "Opus 5",
57    input: 5,
58    output: 25,
59    cacheRead: 0.5,
60    cacheWrite: 6.25,
61    cacheWrite1h: 10,
62  },
63  "claude-opus-4-8": {
64    label: "Opus 4.8",
65    input: 5,
66    output: 25,
67    cacheRead: 0.5,
68    cacheWrite: 6.25,
69    cacheWrite1h: 10,
70  },
71  "claude-opus-4-7": {
72    label: "Opus 4.7",
73    input: 5,
74    output: 25,
75    cacheRead: 0.5,
76    cacheWrite: 6.25,
77    cacheWrite1h: 10,
78  },
79  "claude-opus-4-6": {
80    label: "Opus 4.6",
81    input: 5,
82    output: 25,
83    cacheRead: 0.5,
84    cacheWrite: 6.25,
85    cacheWrite1h: 10,
86  },
87  "claude-sonnet-5-5": {
88    label: "Sonnet 5.5",
89    input: 2,
90    output: 10,
91    cacheRead: 0.2,
92    cacheWrite: 2.5,
93    cacheWrite1h: 4,
94  },
95  "claude-sonnet-5": {
96    label: "Sonnet 5",
97    input: 2,
98    output: 10,
99    cacheRead: 0.2,
100    cacheWrite: 2.5,
101    cacheWrite1h: 4,
102  },
103  "claude-sonnet-4-6": {
104    label: "Sonnet 4.6",
105    input: 3,
106    output: 15,
107    cacheRead: 0.3,
108    cacheWrite: 3.75,
109    cacheWrite1h: 6,
110  },
111  "claude-haiku-4-5": {
112    label: "Haiku 4.5",
113    input: 1,
114    output: 5,
115    cacheRead: 0.1,
116    cacheWrite: 1.25,
117    cacheWrite1h: 2,
118  },
119};
120