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…

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.
Thinking · $0.18 · 61% of 5h…61% of 5h · ~2h 25m left at this pace · resets 01:00optimaizr: this turn $0.18 · 4 requests · 5h 55% → 56%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.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.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.| Option | Default | What it does |
|---|---|---|
turnLine | true | The line under each answer with what it cost. |
retryGuard | true | Hold a command that failed twice unchanged. |
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.
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.
hooks/register.tsx 998 lines1import 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}
998hooks/meter.ts 575 lines1// 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}
575hooks/prices.ts 120 lines1// 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