Pasek budżetu nad promptem: tokeny / okno auto-kompaktacji (próg WARN jak hook kanon-budzet), limity konta 5h/7d, koszt sesji; /budzet pokazuje też model i…

Mod Claude Code (API „function hooks”, early access; pisany pod 2.1.293, testy zielone też na CLI 2.1.289). Nice-to-have z planu modów 2026-10-10; v0.2 = M2 planu (limity konta, koszt, model i cache ostatniego żądania, session.measure, log JSONL — D4). Nad promptem jedna linia:
████████░░░░░░░░░░░░░░░░░░░░░░ 142k / 300k (47 %) WARN 220k · kompaktacja ~280k 5h 37 % · 7d 61 % $0.42 [ Ukryj ]
$.session.usage() (tokeny kontekstu, rateLimits 5h/7d z czasem resetu, cost.usd; wywołanie bez breakdown nic nie kosztuje) oraz zdarzenie session.measure — te same liczby PCHANE przez silnik po każdej turze głównego wątku i gdy okno limitu ruszy o cały punkt (bez dodatkowego wywołania).CLAUDE_CODE_AUTO_COMPACT_WINDOW → settings.autoCompactWindow → settings.modelSettings[<model sesji>].autoCompactWindow → okno modelu z silnika (ta sama kolejność co w kanonie: env wygrywa).kanon-budzet: WARN = KANON_CTX_WARN albo okno − 80k (okno ≥ 200k; poniżej − 40k); kompaktacja ≈ okno − 20k. Kolor paska: zielony → żółty od WARN → czerwony od progu kompaktacji. Toast przy pierwszym przekroczeniu WARN w cyklu (reset po session.compact) z przypomnieniem o sekcji „## Stan” w ~/.claude/kanon/stan/<session_id>.md.5h 37 % · 7d 61 %): kolor wg progów DOMYŚLNYCH, bez pomiaru — 5h żółty ≥ 70 %, czerwony ≥ 90 %; 7d żółty ≥ 50 %, czerwony ≥ 80 % (raport tygodniowy daje 🔴, gdy 2 dni > 40 % okna 7 dni). Toast raz po wejściu w czerwień, ponownie dopiero po spadku poniżej progu. Poza subskrypcją i przed pierwszą odpowiedzią API segmentu nie ma./budzet — trzy linie: kontekst (ze źródłem okna), limity z czasem do resetu i kosztem sesji, ostatnie żądanie głównego wątku (model z usage odpowiedzi, nie alias; udział cache). /budzet ukryj / /budzet pokaz — pasek on/off (przycisk „Ukryj” to samo). Poniżej 110 kolumn pasek chowa progi i koszt, limity zostają.Po każdej turze głównego wątku (nie agentów) mod dopisuje JEDNĄ linię do ~/.claude/kanon/usage/sesje/sesja-<session_id>.jsonl ($.fs.write zakłada katalog; brak append w API → odczyt + zapis całości; powyżej 2 MiB przestaje dopisywać):
{"t":"2026-10-10T10:00:00.000Z","sid":"…","turn":"…","reason":"answer","ms":8123,"model":"claude-opus-5-5","in":3000,"cache_write":0,"cache_read":58000,"out":1200,"ctx":142000,"p5h":37,"p7d":61,"usd":0.42}
Same liczby i model — bez treści promptów, odpowiedzi i narzędzi: test porównuje DOKŁADNY zestaw 14 kluczy (toEqual), więc żadne pole poza wymienionymi nie przejdzie niezauważone. usd to koszt sesji NARASTAJĄCO (cost.usd silnika), nie koszt tury — konsument liczy różnicę kolejnych linii. ctx/p5h/p7d/usd pochodzą z odświeżenia TEJ tury; gdy $.session.usage() padło, są null (nie stare liczby). Tura bez usage (przerwana, błąd API) nie zostawia linii. KANON_USAGE_LOG=0 w env wyłącza. Zapis chroni historię: exists → stat (rozmiar > 2 MiB = stop + jeden toast) → read → write; każdy błąd stat/read poza brakiem pliku przerywa zapis BEZ dotykania pliku (EACCES nie może nadpisać logu pustą historią); historia bez końcowego \n dostaje go przed nową linią. To jedyny zapis modu na dysk (walidator: $.fs.exists, $.fs.read, $.fs.stat, $.fs.write via dopiszLog; ścieżki walidator nie pokazuje — dowodem jest test). Odbiorca: raport tygodniowy usage-report-weekly.sh może z tego liczyć koszt per model i udział cache bez parsowania transkryptów (do zrobienia osobno).
Na stałe: ścieżka modu w env.CLAUDE_CODE_PLUGIN_DIRS w ~/.claude/settings.json (z backupem) — działa od NOWEJ sesji (także desktop); folder jest obserwowany, zapis pliku przeładowuje mod. Jedna sesja z terminala bez env:
claude --plugin-dir ~/Developer/claude-mods/kanon-pasek-budzetu
claude plugin validate ~/Developer/claude-mods/kanon-pasek-budzetu
claude plugin test ~/Developer/claude-mods/kanon-pasek-budzetu
21 testów (tests/pasek.test.ts): pasek po turn.complete z limitami i kosztem na terminalu i desktopie, wąski terminal, cisza bez odczytu i przy ankiecie, toast WARN raz na cykl i reset po kompaktacji, /budzet z oknem z env / settings / modelSettings / silnika i trzema liniami (reset „za 2 h 10 min” / „za 3 d 4 h”), brak limitów i kroku, krok agenta nie nadpisuje głównego wątku, próg KANON_CTX_WARN, ukryj/pokaz, session.measure bez wywołania session.usage + toast czerwonego 7d z resetem flagi, log JSONL (dokładna linia, dopisywanie, brak treści), jego cisza (KANON_USAGE_LOG=0; tura agenta i tura bez usage), ochrona historii (read odmawia → plik nietknięty; brak końcowego \n; plik > 2 MiB → stop + jeden toast; odświeżenie padło → null zamiast starych liczb) i współbieżność session.measure + turn.complete → jeden toast limitu (kolejka modułu).
Lekcje z zestawu testowego (2026-10-10): referencja atomu musi mieć literały plugin/key (stała nie przechodzi skanu walidatora); hooki testu pod modem odpowiadają rzeczownikom (session.usage, settings.read, ui.toast, fs.stat/fs.read/fs.write…) obiektem { value } — brak pliku odgrywa się rzuceniem w hooku fs.stat — a zdarzeniom rdzenia (turn.complete, turn.step jako generator, session.measure → { changed }, command.run, session.compact, ui.render) wynikiem jak silnik; session.compact w teście wymaga co najmniej jednej wiadomości w messages; mock.clock(on, { now }) ustala czas dla ISO w logu i „reset za”.
git revert commitu v0.2 (wraca v0.1 bez logu i limitów), rm -rf ~/.claude/kanon/usage/sesje, usunięcie ścieżki z CLAUDE_CODE_PLUGIN_DIRS albo cp backupu ~/.claude/settings.json.bak-<ts>.
hooks/register.tsx 296 lines1// kanon-pasek-budzetu — pasek budżetu nad promptem (mod Claude Code, API function hooks 2.1.293; early access). v0.2.
2// Liczby: $.session.usage() (context.tokens / percent / window, rateLimits 5h/7d, cost.usd — wywołanie bez breakdown jest darmowe)
3// oraz zdarzenie session.measure (te same liczby PCHANE przez silnik po każdej turze głównego wątku i gdy okno limitu ruszy o punkt).
4// Okno BUDŻETU = env CLAUDE_CODE_AUTO_COMPACT_WINDOW (ma pierwszeństwo, jak w kanonie) → settings.autoCompactWindow →
5// settings.modelSettings[<model sesji>].autoCompactWindow → okno modelu z silnika. Progi jak hook Stop `kanon-budzet`:
6// WARN = KANON_CTX_WARN albo okno − 80k (okno ≥ 200k; poniżej − 40k); kompaktacja ≈ okno − 20k (mediana pomiaru 2026-09-25).
7// Różnica wobec hooka: mod nie czyta transkryptu, więc nie obniża WARN do mediany realnych punktów kompaktacji sesji —
8// pasek jest podglądem, hook Stop pozostaje źródłem bloku i progu obowiązującego.
9// Ostatnie żądanie głównego wątku (turn.step bez agentId): model, który odpowiedział, i udział cache — prawda z usage, nie z aliasu.
10// Log zużycia (D4 planu modów 2026-10-10): po każdej turze głównego wątku jedna linia JSONL z SAMYMI LICZBAMI (model, tokeny,
11// kontekst, limity, koszt) do ~/.claude/kanon/usage/sesje/sesja-<session_id>.jsonl — bez treści promptów ani odpowiedzi.
12// KANON_USAGE_LOG=0 wyłącza. To jedyny zapis na dysk; poza nim mod trzyma tylko stan sesji ($.state), nie używa sieci, nie zmienia
13// ani nie zatwierdza wywołań narzędzi (tool.call służy wyłącznie do odświeżenia odczytu PO `next`). /budzet [ukryj|pokaz].
14// Pomiary z session.measure i z turn.complete mogą biec równocześnie — `zastosuj` idzie przez kolejkę modułu, żeby flagi toastów
15// i para odczyt/limity nie przeplatały się (recenzja 2026-10-10).
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register, SessionCost, SessionRateLimit, SessionUsage, TurnCompleteInput } from 'claude-code'
18
19import type { Krok, Limity, Odczyt } from '../types'
20
21// Skaner walidatora wymaga literałów plugin/key w każdym atomie (nie stałej) — stąd powtórzona nazwa.
22const odczyt = atom({ plugin: 'kanon-pasek-budzetu', key: 'odczyt' } as const, null)
23const limity = atom({ plugin: 'kanon-pasek-budzetu', key: 'limity' } as const, null)
24const krok = atom({ plugin: 'kanon-pasek-budzetu', key: 'krok' } as const, null)
25const ukryty = atom({ plugin: 'kanon-pasek-budzetu', key: 'ukryty' } as const, false)
26const ostrzezono = atom({ plugin: 'kanon-pasek-budzetu', key: 'ostrzezono' } as const, false)
27const ostrzezonoLimit = atom({ plugin: 'kanon-pasek-budzetu', key: 'ostrzezonoLimit' } as const, false)
28
29const CO_ILE_NARZEDZI = 5 // odświeżenie w trakcie tury co tyle wywołań narzędzi (poza tym po każdej turze, pomiarze i kompaktacji)
30// Progi kolorów okien limitu — DOMYŚLNE, bez pomiaru (raport tygodniowy: 🔴 gdy 2 dni > 40 % okna 7 dni).
31const PROGI = { p5h: { warn: 70, error: 90 }, p7d: { warn: 50, error: 80 } }
32const LIMIT_LOGU = 2 * 1024 * 1024 // bajtów; powyżej mod przestaje dopisywać (jeden toast) — $.fs.read odmawia od 4 MiB
33
34const k = (n: number) => `${Math.round(n / 1000)}k`
35const liczba = (v: unknown): number | null => {
36 const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN
37 return Number.isFinite(n) && n > 0 ? n : null
38}
39
40// Kolejka modułu: pomiary (measure / turn.complete / tool.call) wykonują się po kolei, nie przeplatają flag ani atomów.
41let kolejka: Promise<unknown> = Promise.resolve()
42const szereguj = <T,>(f: () => Promise<T>): Promise<T> => {
43 const p = kolejka.then(f, f)
44 kolejka = p.catch(() => undefined)
45 return p
46}
47
48async function oknoBudzetu($: EngineInterface, oknoModelu: number): Promise<Pick<Odczyt, 'okno' | 'zrodlo'>> {
49 const zEnv = liczba(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW'))
50 if (zEnv) return { okno: zEnv, zrodlo: 'env' }
51 const ustawienia = await $.settings.read()
52 const zSettings = liczba(ustawienia.autoCompactWindow)
53 if (zSettings) return { okno: zSettings, zrodlo: 'settings' }
54 const model = await $.session.model()
55 const perModel = ustawienia.modelSettings as Record<string, { autoCompactWindow?: unknown } | undefined> | undefined
56 const zModelu = liczba(perModel?.[model]?.autoCompactWindow)
57 if (zModelu) return { okno: zModelu, zrodlo: 'settings' }
58 return { okno: oknoModelu, zrodlo: 'silnik' }
59}
60
61type Pomiar = Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>
62type Wynik = { o: Odczyt | null; l: Limity | null }
63
64const okno = (r: SessionRateLimit[], kind: string) => r.find(x => x.kind === kind)
65const limityZ = (r: SessionRateLimit[], cost: SessionCost | undefined): Limity => {
66 const a = okno(r, 'five_hour')
67 const b = okno(r, 'seven_day')
68 return { p5h: a?.percentUsed ?? null, reset5h: a?.resetsAt ?? null, p7d: b?.percentUsed ?? null, reset7d: b?.resetsAt ?? null, usd: cost?.usd ?? null }
69}
70const poziom = (p: number | null, progi: { warn: number; error: number }) => (p === null ? 'brak' : p >= progi.error ? 'error' : p >= progi.warn ? 'warning' : 'ok')
71const kolorLimitow = (l: Limity): 'error' | 'warning' | 'success' => {
72 const a = poziom(l.p5h, PROGI.p5h)
73 const b = poziom(l.p7d, PROGI.p7d)
74 return a === 'error' || b === 'error' ? 'error' : a === 'warning' || b === 'warning' ? 'warning' : 'success'
75}
76const resetZa = (iso: string | null, teraz: number): string => {
77 const ms = iso ? Date.parse(iso) - teraz : NaN
78 if (!Number.isFinite(ms) || ms <= 0) return ''
79 const min = Math.ceil(ms / 60_000)
80 const h = Math.floor(min / 60)
81 const m = min % 60
82 return h >= 48 ? `reset za ${Math.floor(h / 24)} d ${h % 24} h` : h >= 1 ? `reset za ${h} h ${m} min` : `reset za ${m} min`
83}
84const procent = (p: number | null) => (p === null ? '—' : `${p} %`)
85
86/** Zastosuj pomiar (z $.session.usage() albo z session.measure): limity, koszt, kontekst; toasty przy pierwszym przekroczeniu. */
87const zastosuj = ($: EngineInterface, u: Pomiar): Promise<Wynik> => szereguj(() => zastosujTeraz($, u))
88
89async function zastosujTeraz($: EngineInterface, u: Pomiar): Promise<Wynik> {
90 const l = limityZ(u.rateLimits, u.cost)
91 await update($, limity, () => l)
92 const czerwone = poziom(l.p5h, PROGI.p5h) === 'error' || poziom(l.p7d, PROGI.p7d) === 'error'
93 if (czerwone) {
94 if (!(await read($, ostrzezonoLimit))) {
95 await update($, ostrzezonoLimit, () => true)
96 $.ui.toast(`Limit konta: 5h ${procent(l.p5h)} · 7d ${procent(l.p7d)} — ciężkie zadania (fan-out, Fable) odłóż za reset okna`, { timeoutMs: 15_000 })
97 }
98 } else if (await read($, ostrzezonoLimit)) {
99 await update($, ostrzezonoLimit, () => false)
100 }
101
102 const { tokens: zSilnika, percent, window: oknoModelu } = u.context
103 const tokens = zSilnika ?? (percent != null ? Math.round((percent / 100) * oknoModelu) : null)
104 if (tokens === null) return { o: null, l }
105 const { okno: oknoB, zrodlo } = await oknoBudzetu($, oknoModelu)
106 const warn = liczba(await $.env.get('KANON_CTX_WARN')) ?? (oknoB >= 200_000 ? oknoB - 80_000 : oknoB - 40_000)
107 const o: Odczyt = { tokens, okno: oknoB, oknoModelu, warn, kompaktacja: oknoB - 20_000, zrodlo }
108 await update($, odczyt, () => o)
109
110 if (tokens >= warn) {
111 if (!(await read($, ostrzezono))) {
112 await update($, ostrzezono, () => true)
113 const sid = await $.session.id()
114 $.ui.toast(`Kontekst ${k(tokens)} ≥ WARN ${k(warn)} — dopisz „## Stan” (zrobione / dalej / decyzje) do ~/.claude/kanon/stan/${sid}.md`, {
115 timeoutMs: 15_000,
116 })
117 }
118 } else if (await read($, ostrzezono)) {
119 await update($, ostrzezono, () => false) // kontekst spadł bez session.compact (/clear, wznowienie) — następne WARN znów ostrzeże
120 }
121 return { o, l }
122}
123
124const odswiez = async ($: EngineInterface): Promise<Wynik> => zastosuj($, await $.session.usage())
125
126// Hooki bramkujące nie mogą paść przez odczyt budżetu — błąd = brak odświeżenia, a log dostaje null zamiast starych liczb.
127const odswiezBezpiecznie = ($: EngineInterface): Promise<Wynik> => odswiez($).catch(() => ({ o: null, l: null }))
128
129let logPelny = false // toast o pełnym logu raz na życie modułu
130
131/** D4: jedna linia JSONL na turę głównego wątku — same liczby i model, bez treści. Liczby z TEGO odświeżenia (null, gdy padło). */
132async function dopiszLog($: EngineInterface, e: TurnCompleteInput, { o, l }: Wynik): Promise<void> {
133 if ((await $.env.get('KANON_USAGE_LOG')) === '0') return
134 const home = await $.env.get('HOME')
135 const u = e.usage
136 if (!home || !u) return // bez HOME nie ma gdzie; bez usage nic nie policzono (przerwanie, błąd API)
137 const [sid, teraz] = await Promise.all([$.session.id(), $.clock.now()])
138 const sciezka = `${home}/.claude/kanon/usage/sesje/sesja-${sid}.jsonl`
139 const linia = JSON.stringify({
140 t: new Date(teraz).toISOString(),
141 sid,
142 turn: e.turnId,
143 reason: e.reason,
144 ms: e.durationMs,
145 model: u.model,
146 in: u.input_tokens,
147 cache_write: u.cache_creation_input_tokens,
148 cache_read: u.cache_read_input_tokens,
149 out: u.output_tokens,
150 ctx: o?.tokens ?? null,
151 p5h: l?.p5h ?? null,
152 p7d: l?.p7d ?? null,
153 usd: l?.usd ?? null, // koszt sesji NARASTAJĄCO (cost.usd), nie koszt tury — różnica kolejnych linii = koszt tury
154 })
155 // Brak pliku = pusta historia; KAŻDY inny błąd stat/read leci wyżej i nic nie jest pisane — historia nie może zostać nadpisana.
156 let stare = ''
157 if (await $.fs.exists(sciezka)) {
158 const st = await $.fs.stat(sciezka)
159 if (st.size > LIMIT_LOGU) {
160 if (!logPelny) {
161 logPelny = true
162 $.ui.toast(`Log zużycia przekroczył ${k(LIMIT_LOGU)} B — mod przestał dopisywać: ${sciezka}`, { timeoutMs: 15_000 })
163 }
164 return
165 }
166 stare = String(await $.fs.read(sciezka))
167 if (stare && !stare.endsWith('\n')) stare += '\n' // ręczna edycja / przerwany zapis nie skleja dwóch rekordów
168 }
169 await $.fs.write(sciezka, `${stare}${linia}\n`)
170}
171
172const opisKontekstu = (o: Odczyt) =>
173 `Kontekst: ${k(o.tokens)} / ${k(o.okno)} (${Math.round((o.tokens / o.okno) * 100)} %) · WARN ${k(o.warn)} · kompaktacja ~${k(o.kompaktacja)}` +
174 ` · okno budżetu z: ${o.zrodlo}${o.okno !== o.oknoModelu ? ` (okno modelu ${k(o.oknoModelu)})` : ''}`
175const opisLimitow = (l: Limity | null, teraz: number) => {
176 if (!l || (l.p5h === null && l.p7d === null && l.usd === null)) return 'Limity: brak odczytu (poza subskrypcją albo przed pierwszą odpowiedzią API)'
177 const r5 = resetZa(l.reset5h, teraz)
178 const r7 = resetZa(l.reset7d, teraz)
179 return (
180 `Limity: 5h ${procent(l.p5h)}${r5 ? ` (${r5})` : ''} · 7d ${procent(l.p7d)}${r7 ? ` (${r7})` : ''}` +
181 (l.usd !== null ? ` · koszt sesji $${l.usd.toFixed(2)}` : '')
182 )
183}
184const opisKroku = (s: Krok | null) =>
185 s
186 ? `Ostatnie żądanie: ${s.model} · ${k(s.wejscie)} in (${k(s.cache)} z cache = ${s.wejscie ? Math.round((s.cache / s.wejscie) * 100) : 0} %) · ${k(s.wyjscie)} out`
187 : 'Ostatnie żądanie: brak kroku głównego wątku od załadowania modu'
188
189export const register: Register = on => {
190 let narzedzia = 0
191
192 on('session.start', async ($, e, next) => {
193 await $.command.register({
194 name: 'budzet',
195 description: 'Budżet: kontekst, limity 5h/7d, koszt sesji, model i cache ostatniego żądania; „ukryj” / „pokaz” steruje paskiem',
196 argumentHint: '[ukryj|pokaz]',
197 })
198 const r = await next(e)
199 await odswiezBezpiecznie($)
200 return r
201 })
202
203 on('turn.complete', async ($, e, next) => {
204 const r = await next(e)
205 if (e.agentId) return r // tury agentów liczy kanon-agenci; pasek i log dotyczą głównego wątku
206 narzedzia = 0
207 const w = await odswiezBezpiecznie($)
208 try {
209 await dopiszLog($, e, w)
210 } catch {
211 // log jest pomocniczy: błąd dysku nie dotyka tury i nie nadpisuje historii
212 }
213 return r
214 }).catch(($, e, next) => next(e))
215
216 on('turn.step', async function* ($, e, next) {
217 const r = yield* next(e)
218 if (!e.agentId && r.usage) {
219 const u = r.usage
220 try {
221 await update($, krok, () => ({
222 model: u.model,
223 wejscie: u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens,
224 cache: u.cache_read_input_tokens,
225 wyjscie: u.output_tokens,
226 }))
227 } catch {
228 // obserwator
229 }
230 }
231 return r
232 })
233
234 on('session.measure', async ($, e, next) => {
235 const r = await next(e)
236 await zastosuj($, e).catch(() => null)
237 return r
238 }).catch(($, e, next) => next(e))
239
240 on('tool.call', async ($, e, next) => {
241 const r = await next(e)
242 narzedzia += 1
243 if (narzedzia % CO_ILE_NARZEDZI === 0) await odswiezBezpiecznie($)
244 return r
245 }).catch(($, e, next) => next(e))
246
247 on('session.compact', async ($, e, next) => {
248 const r = await next(e)
249 await update($, ostrzezono, () => false).catch(() => undefined)
250 await odswiezBezpiecznie($)
251 return r
252 }).catch(($, e, next) => next(e))
253
254 on('command.run', { command: 'budzet' }, async ($, e) => {
255 const arg = e.args.trim().toLowerCase()
256 if (arg === 'ukryj') {
257 await update($, ukryty, () => true)
258 return { text: 'Pasek budżetu ukryty do końca sesji („/budzet pokaz” przywraca).' }
259 }
260 if (arg === 'pokaz') await update($, ukryty, () => false)
261 const { o } = await odswiezBezpiecznie($)
262 const [l, s, teraz] = await Promise.all([read($, limity), read($, krok), $.clock.now()])
263 return {
264 text: [o ? opisKontekstu(o) : 'Kontekst: brak odczytu (silnik nie podał liczby tokenów ani procentu).', opisLimitow(l, teraz), opisKroku(s)].join('\n'),
265 }
266 })
267
268 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
269 const o = await read($, odczyt)
270 const cicho = e.props.hasSurvey || e.props.maxRows < 1 || o === null || (await read($, ukryty))
271 if (cicho) return next(e)
272
273 const { Box, Button, Text } = $.ui.resolve(e)
274 const l = await read($, limity)
275 const szerokie = e.props.bodyColumns >= 110
276 const szerokosc = Math.max(10, Math.min(szerokie ? 30 : 16, Math.floor(e.props.bodyColumns / 5)))
277 const pelne = Math.max(0, Math.min(szerokosc, Math.round((o.tokens / o.okno) * szerokosc)))
278 const pasek = '█'.repeat(pelne) + '░'.repeat(szerokosc - pelne)
279 const kolor = o.tokens >= o.kompaktacja ? 'error' : o.tokens >= o.warn ? 'warning' : 'success'
280 const liczby = `${k(o.tokens)} / ${k(o.okno)} (${Math.round((o.tokens / o.okno) * 100)} %)`
281 const progi = `WARN ${k(o.warn)} · kompaktacja ~${k(o.kompaktacja)}`
282 const maLimity = l !== null && (l.p5h !== null || l.p7d !== null)
283
284 return (
285 <Box gap={1}>
286 <Text color={kolor}>{pasek}</Text>
287 <Text bold={o.tokens >= o.warn}>{liczby}</Text>
288 {szerokie && <Text dimColor>{progi}</Text>}
289 {maLimity && <Text color={kolorLimitow(l)}>{`5h ${procent(l.p5h)} · 7d ${procent(l.p7d)}`}</Text>}
290 {szerokie && l?.usd != null && <Text dimColor>{`$${l.usd.toFixed(2)}`}</Text>}
291 <Button key="ukryj" label="Ukryj" onPress={() => update($, ukryty, () => true)} />
292 </Box>
293 )
294 })
295}
296types/index.d.ts 53 lines1// Kontrakt stanu modu kanon-pasek-budzetu (odczyt w $.state sesji; po reloadzie modułu wartości zostają).
2export type Odczyt = {
3 /** Tokeny kontekstu z $.session.usage() (context.tokens, w odwodzie percent × okno modelu). */
4 tokens: number
5 /** Okno BUDŻETU (próg auto-kompaktacji): env CLAUDE_CODE_AUTO_COMPACT_WINDOW → settings → okno modelu z silnika. */
6 okno: number
7 /** Okno modelu, jakie podał silnik (context.window) — do porównania, gdy budżet jest mniejszy. */
8 oknoModelu: number
9 /** Próg ostrzeżenia hooka kanon-budzet: KANON_CTX_WARN albo okno − 80k (okno ≥ 200k; poniżej − 40k). */
10 warn: number
11 /** Spodziewany punkt auto-kompaktacji ≈ okno − 20k (mediana pomiaru 78 kompaktacji, 2026-09-25). */
12 kompaktacja: number
13 zrodlo: 'env' | 'settings' | 'silnik'
14}
15
16/** Okna limitów konta i koszt sesji — z `rateLimits`/`cost` w $.session.usage() albo w zdarzeniu session.measure. */
17export type Limity = {
18 /** Okno 5 h: procent zużycia (0–100, jedno miejsce po przecinku) z ostatniej odpowiedzi API; null poza subskrypcją albo przed 1. odczytem. */
19 p5h: number | null
20 /** Reset okna 5 h, ISO 8601; null, gdy silnik go nie podał. */
21 reset5h: string | null
22 /** Okno 7 dni (limit tygodniowy — ten z raportu usage-report-weekly). */
23 p7d: number | null
24 reset7d: string | null
25 /** Koszt sesji w USD, jak liczy /cost; null, gdy host nie prowadzi księgi. */
26 usd: number | null
27}
28
29/** Ostatnie żądanie głównego wątku (turn.step bez agentId) — model, który ODPOWIEDZIAŁ, i jego tokeny. */
30export type Krok = {
31 model: string
32 /** Tokeny wejścia razem: niecache + zapis cache + odczyt cache. */
33 wejscie: number
34 /** Z tego odczyt z cache (cache_read_input_tokens). */
35 cache: number
36 wyjscie: number
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'kanon-pasek-budzetu': {
42 odczyt: Odczyt | null
43 limity: Limity | null
44 krok: Krok | null
45 ukryty: boolean
46 /** Toast WARN kontekstu już pokazany w tym cyklu kompaktacji. */
47 ostrzezono: boolean
48 /** Toast o limicie konta (5h/7d ≥ próg czerwony) już pokazany; zeruje się, gdy odczyt spadnie poniżej. */
49 ostrzezonoLimit: boolean
50 }
51 }
52}
53