Przekazanie pałeczki do nowej sesji: pasek nad promptem po kompaktacji, po 8 h albo przy kontekście ≥ WARN (formuła hooka kanon-budzet); /sztafeta pokazuje…

Mod Claude Code (API „function hooks”, early access; pisany pod 2.1.299, testy zielone na desktop 2.1.299 i CLI 2.1.302). v0.1.1 (2026-10-17): pasek składa łańcuch AbovePrompt (next(e)) zamiast zasłaniać pluginy niżej. Z planu claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §3.1 (decyzja D1 2026-10-16: z przyciskiem start_session). Problem z logów: sesje żyją po kilkanaście godzin i kilkadziesiąt kompaktacji, a stan do wznowienia leży w pliku hooka kanon-stan (~/.claude/kanon/stan/<session_id>.md), który rośnie do setek KB — nowa sesja nie ma czego „wczytać” w rozsądnym rozmiarze.
session.compact; kompaktacje subagentów i precompute się nie liczą),$.session.usage().startedAt; gdy silnik go nie zna — zegar przy session.start, odświeżany timerem co 5 min),kanon-pasek-budzetu: KANON_CTX_WARN albo okno budżetu − 80k (okno ≥ 200k; poniżej − 40k); okno z CLAUDE_CODE_AUTO_COMPACT_WINDOW → settings.autoCompactWindow → modelSettings[model].autoCompactWindow → okno modelu z silnika. Różnica wobec hooka Stop kanon-budzet: mod nie czyta transkryptu, więc nie obniża WARN do mediany realnych punktów kompaktacji sesji (− 40k) ani nie zna jego podłogi i wartości domyślnej bez okna — pasek jest podglądem, hook pozostaje bramką (po pierwszej kompaktacji pasek i tak już jest z warunku 1). Treść: ⟳ sztafeta: 3 kompaktacje · sesja 6 h · stan: 22:23 (12 min temu, „## Stan” #4) · [Przekaż do nowej sesji] [Pokaż „dalej”] (+ kontekst 225k ≥ WARN 220k, gdy to warunek 3). „stan” to godzina z nagłówka OSTATNIEJ sekcji ## Stan pliku stanu (bez godziny w nagłówku — z mtime), wiek pliku i liczba sekcji; bez pliku — stan: brak pliku; plik z samymi blokami hooka — stan: plik bez „## Stan”. Pasek ustępuje ankiecie (hasSurvey) i milczy przy maxRows < 1. Pasmo AbovePrompt rysuje JEDEN hook (d.ts: „draws a tree, or passes; one instance”): pasek składa pod sobą to, co narysowałby plugin niżej w łańcuchu (next(e), np. kanon-skrzynka), a gdy milczy, oddaje next(e) bez zmian; błąd pluginu niżej nie gasi paska; przy maxRows < 2 pasek zostaje sam (stos w jednym wierszu przewijałby się w zerowym oknie bodyRows) (v0.1.1). Liczniki żyją w $.state bieżącego procesu: /clear i resume zerują je (hook session.end, bo session.start wtedy nie odpala), a sesja wznowiona w NOWYM procesie (claude --resume) zaczyna od „0 kompaktacji” i wieku od wznowienia — mod nie liczy bloków ## Kompaktacja N z pliku (v0.1)./sztafeta — panel „Sztafeta” z OSTATNIĄ sekcją ## Stan (nie całym plikiem) i briefem dla nowej sesji. Brief jest wycinany deterministycznie, bez modelu: wczytaj <ścieżka skrótu> / dalej: + punkty z podsekcji ### Dalej… / decyzje: + punkty z ### Decyzje… / repo: <cwd>. Sekcja bez tych podsekcji → cała sekcja przycięta do 1500 znaków; plik bez sekcji ## Stan albo brak pliku → komunikat w panelu i w briefie (bez błędu). Gdy żadna powierzchnia nie sadza panelu, sekcja i brief idą w odpowiedzi komendy./sztafeta skrot — zapis ~/.claude/kanon/stan/<session_id>-skrot.md: nagłówek, ostatnia sekcja ## Stan i ostatni blok hooka kanon-stan (## Kompaktacja N — … albo ## Podsumowanie kompaktacji — …), razem ≤ 8 KB. Za dużo → blok kompaktacji przycięty od końca z adnotacją _(przycięto do 8 KB; pełny plik: …)_; gdy sama sekcja ## Stan nie mieści się w limicie, blok wypada i przycina się sekcja. Komenda mówi, ile bajtów zapisała i czy przycięła.$.mcp.call('ccd_session', 'start_session', { initiation: 'own_initiative', title, prompt: <brief>, background, context: 'fresh' }) → karta „Nowe zadanie” w aplikacji, którą właściciel sam uruchamia (nic nie startuje samo). Bez cwd — nowa sesja dziedziczy folder. Odstępstwo od §3.1 planu (tam offer + cwd), ustalone w zleceniu sesji nadrzędnej: typ MCP mówi, że offer to sam tytuł bez briefu (karta czeka na odpowiedź w rozmowie), a cwd służy tylko do innego folderu niż bieżący. Zapis skrótu, który się nie powiedzie, nie blokuje karty ani zapasu (background mówi wtedy „skrótu nie ma”). Gdy serwera nie ma, wywołanie rzuca albo odpowiedź ma isError / zaczyna się od Error → zapas: $.prompt.fill(brief, append) + $.ui.copy(brief) + toast z powodem i miejscem, gdzie brief wylądował (okno promptu / schowek / tylko panel)./sztafeta.$.model, $.http, $.process (linia calls: walidatora niżej). Jedyny zapis na dysk: <session_id>-skrot.md w ~/.claude/kanon/stan/. Stan modu w $.state (kontrakt types/index.d.ts: kompaktacje, start, kontekst, plik, widok, tyk).tool.check nieużywany. Hooki zdarzeń (session.start, session.compact, turn.complete, session.measure) tylko obserwują — każdy z .catch oddającym next(e). Hook command.run{sztafeta} odpowiada za siebie; jego .catch zwraca tekst z powodem (bez next). Walidator wypisuje session.compact jako gating hook with .catch — to fakt, nie ostrzeżenie; a także: hooki session.compact nie biegną w pluginie instalowanym tam, gdzie organizacja zarządza Claude Code (tu: Mac właściciela, bez zarządzania).kanon-budzet: blok tury przy WARN i żądanie sekcji ## Stan zostają bramką w settings.json; mod jest podglądem i wygodą (pasek, skrót, karta)./sztafeta, klik). Największy plik stanu na tym Macu ma 429 KB ($.fs.read odmawia od 4 MiB).## bez śledzenia bloków kodu: niedomknięty płot `` w cudzym tekście wklejonym do „Ostatnia odpowiedź” nie ukryje późniejszych sekcji. H2 inne niż ## Stan…, ## Kompaktacja <cyfra>…, ## Podsumowanie kompaktacji… są pomijane (## Kompaktacja-odporne… to nie blok, ## Standard…` to nie stan).--plugin-dir zapisujący next.trace po next(e) na session.start i turn.complete; claude -p, Sonnet 5.5, 2 × $0,22; wynik IDENTYCZNY na CLI 2.1.302 i desktop 2.1.299): kolejność ogniw = kolejność ścieżek w CLAUDE_CODE_PLUGIN_DIRS, wszystkie w tierze user, plugin z --plugin-dir przed nimi (index 0), rdzeń silnika na dnie: 0 sonda (--plugin-dir) · 1 kanon-agenci · 2 kanon-pasek-budzetu · 3 kanon-flota · 4 kanon-na-biegu · 5 kanon-brief · 6 kanon-sztafeta · 7 engine (core)
Wniosek dla pasma: pierwszy rysuje kanon-pasek-budzetu, jego next(e) trafia do kanon-sztafety, a kanon-skrzynka dopisana na KOŃCU env będzie ostatnia, nad pustym rdzeniem — na ekranie od góry: budżet → sztafeta → skrzynka. Przed tą zmianą pasek budżetu (odczyt kontekstu jest prawie zawsze po pierwszej turze) zwracał własne drzewo bez next(e) i zasłaniał sztafetę całkowicie, a sztafeta — skrzynkę. Inna kolejność linii = inna kolejność ścieżek w env (od nowej sesji), nie zmiana kodu. Dokumentacja (skill plugin-authoring, d.ts) mówi tylko o tierach (prepend, user, append, builtin, core) i o kolejności rejestracji w JEDNYM pluginie — kolejność w tierze user znamy wyłącznie z sondy. Niesprawdzone na żywo: wygląd dwóch–trzech linii naraz w pasmie (wysokość, [-] zwija cały stos, limit maxRows = połowa wierszy terminala, hotkeye przycisków z kilku modów w jednym pasmie). Dla sztafety znaczy to: jej pasek stoi POD paskiem budżetu i nad skrzynką; do 2026-10-17 pasek budżetu zasłaniał go w każdej sesji z odczytem kontekstu (od pierwszej tury) — także dlatego próba na żywo (wyżej) nie mogła go pokazać po pierwszej turze, dopóki kanon-pasek-budzetu nie składał next(e) (v0.2.1).
claude --plugin-dir ~/Developer/claude-mods/kanon-sztafeta (CLI) albo kopia do ~/.claude/dev-mods/<session_id>/kanon-sztafeta/ (desktop; silnik pyta raz o hot reload, mod ładuje się po końcu tury). W świeżej sesji paska nie będzie — /sztafeta działa od razu. Pasek wymusza: /compact (warunek 1) albo KANON_CTX_WARN=1 w env nowej sesji (warunek 3 od pierwszej tury).ccb20c0d z 1 kompaktacją): kopia w ~/.claude/dev-mods/ccb20c0d-…/kanon-sztafeta/ położona; załadowanie zależy od odpowiedzi właściciela na pytanie o hot reload po końcu tury — pasek, /sztafeta i przycisk NIEZWERYFIKOWANE na żywo w tej sesji (patrz „Czy start_session z modu działa”).start_session z modu działaNiezweryfikowane na żywo (2026-10-17). Co wiadomo:
ccd_session z narzędziem start_session jest podłączony do sesji desktop i typowany dla modów (.claude-plugin/types/claude-code-mcp/index.d.ts, który silnik położył przy ładowaniu kanon-na-biegu 2026-10-16) — wywołanie $.mcp.call('ccd_session', 'start_session', …) przechodzi walidator i tsc. Testy pokazują obie gałęzie na mocku: karta (odpowiedź bez isError) i zapas (deny serwera / isError).kanon-na-biegu (README, „Sprawdzone na żywo”, pkt 3): w trybie auto wywołanie MCP spoza żądania modelu (timer) dostało odmowę klasyfikatora „the request that produced this action did not ask for one”; na-biegu obeszło to hookiem tool.check na własny origin. Klik przycisku też jest poza żądaniem modelu, a tool.check jest tu zabroniony — PRAWDOPODOBNE, że w trybie auto karta nie powstanie i zadziała zapas (brief w oknie promptu i w schowku; na desktopie, który rysuje własny composer, prompt.fill może odpowiedzieć no_composer — wtedy tylko schowek). Toast mówi, co się stało. Rozstrzygnięcie: jeden klik na żywo po załadowaniu modu.start_session) — zapas ten sam.claude plugin validate . (2.1.299 i 2.1.302): ✔ Validation passed; hooki: session.start, session.compact, turn.complete, session.measure, command.run{command=sztafeta}, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=sztafeta}; calls: $.clock.every, $.clock.now, $.command.register, $.env.get, $.fs.exists, $.fs.read, $.fs.stat, $.fs.write, $.mcp.call, $.prompt.fill, $.session.cwd, $.session.id, $.session.model, $.session.usage, $.settings.read, $.state.get, $.state.set, $.ui.close, $.ui.copy, $.ui.open, $.ui.resolve, $.ui.toast; env reads: CLAUDE_CODE_AUTO_COMPACT_WINDOW, HOME, KANON_CTX_WARN; env writes: nic.tsc -p . strict (TypeScript 6.0.3 z stelmach/wishlist/node_modules): czysto. tsconfig.json rozszerza ten, który silnik kładzie w .claude-plugin/types/ przy ładowaniu (gitignored); validate/test go nie kładą — przed załadowaniem skopiuj z innej kopii dev-mods.claude plugin test .: 24 testy zielone na CLI 2.1.302 i desktop 2.1.299 (tests/sztafeta.test.ts): pasek po kompaktacji (agent, precompute i zawetowana skip nie liczą się), po 8 h (z startedAt i z zegara, timer co 5 min), przy WARN (tura agenta nie liczy się), brak w świeżej sesji i po /clear; parser na syntetycznym pliku ≥ 300 KB i godzinaZ (data, „Z”, czas trwania to nie godzina); brief z „dalej”/„decyzje”, z dopisku bez podsekcji, bez sekcji; skrót bez i z przycięciem (≤ 8 KB, adnotacja, granica 8191/8192 B); klik → start_session z briefem (terminal i desktop); zapas przy deny, przy isError i przy tekście „Error: …”; panel, „Pokaż „dalej””, „Zamknij”; brak pliku; plik bez ## Stan; fs.read odmawia (komenda odpowiada tekstem z powodem); łańcuch AbovePrompt (3 testy: dno ui.render rysuje Text-strażnik → w świeżej sesji sam, po kompaktacji POD linią paska w kolumnie flexDirection, przy maxRows 1 pasek sam; dno rdzenia = pusty Box bez dzieci; dno rzuca → pasek zostaje). Fixtures (tests/fixtures.ts) są syntetyczne — kształt realnych plików, bez treści sesji.~/.claude/kanon/stan/*.md (transpilowany register.tsx, tylko liczby na wyjściu, 2026-10-17): 45 z sekcją ## Stan, 46 z blokiem kompaktacji, 32 briefy z „dalej” i 32 z „decyzje”, 23 skróty przycięte, największy skrót 8192 B (limit 8192), 4 nagłówki bez godziny (→ mtime).Twierdzenia (validate, testy, tsc, bramki, parser) nieobalone; 10 mutacji na kopii: 6 złapanych, 4 przeżyte → dopisane testy (skip, turn.complete agenta, isError bez prefiksu / prefiks bez isError, granica 8192 B). Wdrożone: hook session.end (/clear, resume zerują liczniki), godzinaZ kotwiczone do daty (+ „Z” w strefie silnika), zapas przy błędzie zapisu skrótu, README (różnica wobec hooka, wznowienie, odstępstwo od §3.1). Otwarte: próba na żywo (patrz wyżej). v0.1.1 (2026-10-17): recenzent [S5.5HI], 1 runda: 0 🔴, 5 🟡 — wszystkie wdrożone (strażnik maxRows ≥ 2, testy pustego Box, rzucającego dna i kolumny; .gitignore dotyczył kanon-pasek-budzetu).
Usunąć folder z CLAUDE_CODE_PLUGIN_DIRS / dev-mods; mod nie zostawia nic poza <session_id>-skrot.md w ~/.claude/kanon/stan/ (do skasowania ręcznie) i stanem w $.state sesji.
hooks/register.tsx 411 lines1// kanon-sztafeta — przekazanie pałeczki do nowej sesji (mod Claude Code, API function hooks 2.1.299; early access). v0.1.1.
2// Plan: ~/Developer/claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §3.1 (decyzja D1: z przyciskiem start_session).
3// Pasek nad promptem (AbovePrompt) pojawia się dopiero, gdy sesja spełnia co najmniej jeden warunek: ≥ 1 kompaktacja głównego wątku
4// (session.compact), wiek ≥ 8 h ($.session.usage().startedAt, w odwodzie zegar przy session.start), kontekst ≥ WARN (ta sama formuła
5// co kanon-pasek-budzetu: KANON_CTX_WARN albo okno budżetu − 80k; okno z env → settings → modelSettings → silnik; różnica wobec
6// hooka Stop kanon-budzet: mod nie czyta transkryptu, więc nie obniża WARN do mediany realnych punktów kompaktacji sesji — hook
7// pozostaje bramką). W świeżej sesji paska nie ma; /clear i resume zerują liczniki (session.end). Pasmo AbovePrompt rysuje JEDEN hook
8// (d.ts: „draws a tree, or passes; one instance”): pasek składa pod sobą to, co narysowałby plugin niżej w łańcuchu (`next(e)`, np.
9// kanon-skrzynka), a gdy milczy, oddaje `next(e)` bez zmian (2026-10-17, po recenzji kanon-skrzynki).
10// /sztafeta → pane z OSTATNIĄ sekcją „## Stan” pliku ~/.claude/kanon/stan/<sid>.md (nie całym plikiem) i briefem dla nowej sesji
11// wyciętym deterministycznie z podsekcji „### Dalej…” i „### Decyzje…” (bez modelu). /sztafeta skrot → <sid>-skrot.md: ostatnia
12// sekcja „## Stan” + ostatni blok kompaktacji hooka kanon-stan („## Kompaktacja N” / „## Podsumowanie kompaktacji”), razem ≤ 8 KB
13// (przycięcie od końca bloku kompaktacji z adnotacją „przycięto”). Przycisk „Przekaż do nowej sesji” → zapis skrótu +
14// $.mcp.call('ccd_session', 'start_session', { initiation: 'own_initiative', … }) — karta w aplikacji, którą właściciel sam uruchamia;
15// WYŁĄCZNIE na klik. Gdy serwer nie odpowie: $.prompt.fill (append) + $.ui.copy — właściciel wkleja brief sam. Bez `cwd`: nowa sesja
16// dziedziczy folder bieżącej.
17// Bramki: zero $.model, $.http, $.process; jedyny zapis na dysk = <sid>-skrot.md w ~/.claude/kanon/stan/; stan modu w $.state;
18// tool.check nieużywany; hooki zdarzeń tylko obserwują (każdy z .catch). Mod nie zastępuje hooka kanon-budzet — blok tury zostaje bramką.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register, SessionContextUsage, UiPressArgument } from 'claude-code'
21
22import type { Widok } from '../types'
23
24// Skaner walidatora wymaga literałów plugin/key w każdym atomie (nie stałej) — stąd powtórzona nazwa.
25const kompaktacje = atom({ plugin: 'kanon-sztafeta', key: 'kompaktacje' } as const, 0)
26const start = atom({ plugin: 'kanon-sztafeta', key: 'start' } as const, null)
27const kontekst = atom({ plugin: 'kanon-sztafeta', key: 'kontekst' } as const, null)
28const plik = atom({ plugin: 'kanon-sztafeta', key: 'plik' } as const, null)
29const widok = atom({ plugin: 'kanon-sztafeta', key: 'widok' } as const, null)
30const tyk = atom({ plugin: 'kanon-sztafeta', key: 'tyk' } as const, 0)
31
32export const PROG_WIEKU = 8 * 3_600_000
33export const LIMIT_SKROTU = 8 * 1024 // bajtów UTF-8, razem z nagłówkiem i adnotacją
34const CO_ILE_TYK = 5 * 60_000 // odświeżenie wieku sesji na pasku
35const PANE = 'sztafeta'
36const PUNKTOW = 15 // najwyżej tyle punktów z „Dalej” i z „Decyzje” w briefie
37const DL_PUNKTU = 400
38const DL_SEKCJI = 1500 // brief z całej sekcji, gdy nie ma podsekcji
39
40// ---------- parser pliku stanu (czyste funkcje, bez $; testy importują je stąd) ----------
41const NAGLOWEK_STAN = /^## Stan\b/
42const NAGLOWEK_KOMPAKTACJI = /^## (Kompaktacja \d|Podsumowanie kompaktacji\b)/
43
44export type Podzial = {
45 /** Liczba sekcji „## Stan”. */
46 stany: number
47 /** Ostatnia sekcja „## Stan” z nagłówkiem (do następnego „## ”); null, gdy żadnej nie ma. */
48 stan: string | null
49 /** Ostatni blok hooka kanon-stan: „## Kompaktacja N — …” albo „## Podsumowanie kompaktacji — …”. */
50 kompaktacja: string | null
51}
52
53/** Dzieli plik stanu po liniach „## ” (sekcje „## Stan” pisze asystent, bloki kompaktacji hook kanon-stan; bywają inne H2). */
54export function podziel(tekst: string): Podzial {
55 const linie = tekst.split('\n')
56 const od: number[] = []
57 linie.forEach((l, i) => {
58 if (l.startsWith('## ')) od.push(i)
59 })
60 const blok = (k: number) => linie.slice(od[k], od[k + 1] ?? linie.length).join('\n').trimEnd()
61 const p: Podzial = { stany: 0, stan: null, kompaktacja: null }
62 od.forEach((i, k) => {
63 const l = linie[i] ?? ''
64 if (NAGLOWEK_STAN.test(l)) {
65 p.stany += 1
66 p.stan = blok(k)
67 } else if (NAGLOWEK_KOMPAKTACJI.test(l)) p.kompaktacja = blok(k)
68 })
69 return p
70}
71
72export const naglowek = (sekcja: string): string => sekcja.split('\n', 1)[0] ?? ''
73const bezNaglowka = (sekcja: string): string => sekcja.split('\n').slice(1).join('\n').trim()
74const hhmm = (ms: number): string => {
75 const d = new Date(ms)
76 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
77}
78/** Godzina z nagłówka: przy dacie („## Stan 2026-10-16 23:37 (asystent)” → „23:37”; „…2026-10-10T10:16Z…” → jak hhmm, czyli w strefie
79 * silnika, tej samej co zapas z mtime), inaczej pierwsze HH:MM nieprzyklejone do cyfr ani dwukropka; null bez godziny. */
80export const godzinaZ = (linia: string): string | null => {
81 const m = /(\d{4}-\d{2}-\d{2})[ T](\d{1,2}):(\d{2})(Z?)/.exec(linia)
82 if (m) return m[4] ? hhmm(Date.parse(`${m[1]}T${(m[2] ?? '').padStart(2, '0')}:${m[3]}:00Z`)) : `${m[2]}:${m[3]}`
83 return /(?<![\d:])(\d{2}:\d{2})(?![\d:])/.exec(linia)?.[1] ?? null // dwie cyfry godziny: „po 1:30 h pracy” to czas trwania, nie godzina
84}
85export const skroc = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
86
87/** Treść podsekcji „### <prefiks…>” (do następnego „### ”), bez nagłówka; null, gdy jej nie ma. Prefiks bez względu na wielkość liter. */
88export function podsekcja(sekcja: string, prefiks: RegExp): string | null {
89 const linie = sekcja.split('\n')
90 const od = linie.findIndex(l => l.startsWith('### ') && prefiks.test(l.slice(4).trim()))
91 if (od < 0) return null
92 const koniec = linie.findIndex((l, i) => i > od && l.startsWith('### '))
93 return linie.slice(od + 1, koniec < 0 ? linie.length : koniec).join('\n').trim()
94}
95
96/** Punkty listy („- ”, „* ”, „1. ”); bez listy — niepuste linie. Najwyżej PUNKTOW, każdy ≤ DL_PUNKTU znaków. */
97export function punkty(tresc: string): string[] {
98 const linie = tresc.split('\n').map(l => l.trim()).filter(l => l !== '')
99 const lista = linie.filter(l => /^([-*•]|\d+[.)])\s/.test(l))
100 return (lista.length ? lista : linie).slice(0, PUNKTOW).map(l => skroc(l, DL_PUNKTU))
101}
102
103/** Brief dla nowej sesji: „wczytaj <skrót>; dalej: …; decyzje: …; repo: <cwd>” — z nagłówków, bez modelu. */
104export function brief(stan: string | null, skrot: string, cwd: string): string {
105 const linie: string[] = []
106 if (stan === null) linie.push('stan: brak sekcji „## Stan” w pliku stanu tej sesji — ustal z właścicielem, co było robione')
107 else {
108 linie.push(`wczytaj ${skrot}`)
109 const dalej = podsekcja(stan, /^dalej/i)
110 const decyzje = podsekcja(stan, /^decyzje/i)
111 if (dalej) linie.push('dalej:', ...punkty(dalej))
112 if (decyzje) linie.push('decyzje:', ...punkty(decyzje))
113 if (!dalej && !decyzje) linie.push('stan (sekcja bez podsekcji „Dalej” i „Decyzje”):', skroc(bezNaglowka(stan), DL_SEKCJI))
114 }
115 linie.push(`repo: ${cwd}`)
116 return linie.join('\n')
117}
118
119const bajtyZnaku = (c: number): number => (c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0xd800 || c >= 0xe000 ? 3 : c < 0xdc00 ? 4 : 0)
120/** Długość w bajtach UTF-8 (para zastępcza liczona przy pierwszej połówce). Środowisko modu nie ma TextEncoder. */
121export function bajty(s: string): number {
122 let n = 0
123 for (let i = 0; i < s.length; i++) n += bajtyZnaku(s.charCodeAt(i))
124 return n
125}
126/** Przycina od końca do najwyżej `max` bajtów UTF-8, nie rozcinając pary zastępczej. */
127export function utnij(s: string, max: number): string {
128 let n = 0
129 for (let i = 0; i < s.length; i++) {
130 n += bajtyZnaku(s.charCodeAt(i))
131 if (n > max) return s.slice(0, i)
132 }
133 return s
134}
135
136/** Skrót ≤ LIMIT_SKROTU: nagłówek + ostatnia sekcja „## Stan” + ostatni blok kompaktacji; za dużo → blok przycięty od końca z adnotacją. */
137export function zbudujSkrot(p: Podzial, glowa: string, sciezkaPelna: string): { tekst: string; przycieto: boolean } {
138 const stan = `${glowa}\n\n${p.stan ?? ''}`
139 const caly = p.kompaktacja ? `${stan}\n\n${p.kompaktacja}` : stan
140 if (bajty(caly) < LIMIT_SKROTU) return { tekst: `${caly}\n`, przycieto: false }
141 const adnotacja = `\n\n_(przycięto do ${LIMIT_SKROTU / 1024} KB; pełny plik: ${sciezkaPelna})_\n`
142 const budzet = LIMIT_SKROTU - bajty(adnotacja)
143 const naKompaktacje = budzet - bajty(`${stan}\n\n`)
144 const tekst = p.kompaktacja && naKompaktacje >= 200 ? `${stan}\n\n${utnij(p.kompaktacja, naKompaktacje)}` : utnij(stan, budzet)
145 return { tekst: `${tekst}${adnotacja}`, przycieto: true }
146}
147
148// ---------- liczby i teksty paska ----------
149const k = (n: number) => `${Math.round(n / 1000)}k`
150const liczba = (v: unknown): number | null => {
151 const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN
152 return Number.isFinite(n) && n > 0 ? n : null
153}
154/** Odmiana po liczebniku: 1 kompaktacja, 2–4 kompaktacje, 5+ kompaktacji (22–24 znów kompaktacje). */
155export const odmiana = (n: number, jeden: string, kilka: string, wiele: string): string => {
156 const r10 = n % 10
157 const r100 = n % 100
158 return n === 1 ? jeden : r10 >= 2 && r10 <= 4 && (r100 < 12 || r100 > 14) ? kilka : wiele
159}
160export const czasTrwania = (ms: number): string => {
161 const min = Math.max(0, Math.floor(ms / 60_000))
162 return min >= 60 ? `${Math.floor(min / 60)} h` : `${min} min`
163}
164const temu = (ms: number): string => {
165 const min = Math.floor(ms / 60_000)
166 if (min < 1) return 'przed chwilą'
167 if (min < 60) return `${min} min temu`
168 const h = Math.floor(min / 60)
169 return h < 48 ? `${h} h temu` : `${Math.floor(h / 24)} d temu`
170}
171// ---------- plik stanu i kontekst ($) ----------
172async function sciezki($: EngineInterface): Promise<{ sid: string; stan: string; skrot: string }> {
173 const [home, sid] = await Promise.all([$.env.get('HOME'), $.session.id()])
174 if (!home) throw new Error('brak HOME w środowisku')
175 return { sid, stan: `${home}/.claude/kanon/stan/${sid}.md`, skrot: `${home}/.claude/kanon/stan/${sid}-skrot.md` }
176}
177
178/** Pełny odczyt i podział pliku stanu; null, gdy pliku nie ma. ($.fs.read odmawia od 4 MiB — największy plik stanu ma 448 KB.) */
179async function czytaj($: EngineInterface, sciezka: string): Promise<Podzial | null> {
180 if (!(await $.fs.exists(sciezka))) return null
181 return podziel(String(await $.fs.read(sciezka)))
182}
183
184/** Metadane pliku do paska; czyta plik tylko, gdy mtime się zmienił (po kompaktacji i po turze głównego wątku). */
185async function odswiezPlik($: EngineInterface): Promise<void> {
186 const { stan } = await sciezki($)
187 if (!(await $.fs.exists(stan))) {
188 await update($, plik, () => null)
189 return
190 }
191 const st = await $.fs.stat(stan)
192 if ((await read($, plik))?.mtimeMs === st.mtimeMs) return
193 const p = podziel(String(await $.fs.read(stan)))
194 const czas = (p.stan ? godzinaZ(naglowek(p.stan)) : null) ?? hhmm(st.mtimeMs)
195 await update($, plik, () => ({ mtimeMs: st.mtimeMs, sekcje: p.stany, czas }))
196}
197
198async function oknoBudzetu($: EngineInterface, oknoModelu: number): Promise<number> {
199 const zEnv = liczba(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW'))
200 if (zEnv) return zEnv
201 const ustawienia = await $.settings.read()
202 const zSettings = liczba(ustawienia.autoCompactWindow)
203 if (zSettings) return zSettings
204 const model = await $.session.model()
205 const perModel = ustawienia.modelSettings as Record<string, { autoCompactWindow?: unknown } | undefined> | undefined
206 return liczba(perModel?.[model]?.autoCompactWindow) ?? oknoModelu
207}
208
209/** Kontekst głównego wątku i próg WARN — formuła hooka kanon-budzet (jak w kanon-pasek-budzetu). */
210async function zastosujKontekst($: EngineInterface, c: SessionContextUsage): Promise<void> {
211 const tokens = c.tokens ?? (c.percent != null ? Math.round((c.percent / 100) * c.window) : null)
212 if (tokens === null) return
213 const okno = await oknoBudzetu($, c.window)
214 const warn = liczba(await $.env.get('KANON_CTX_WARN')) ?? (okno >= 200_000 ? okno - 80_000 : okno - 40_000)
215 await update($, kontekst, () => ({ tokens, warn }))
216}
217
218async function zbudujWidok($: EngineInterface): Promise<Widok> {
219 const [s, cwd] = await Promise.all([sciezki($), $.session.cwd()])
220 const p = await czytaj($, s.stan)
221 const stan = p?.stan ?? null
222 const w: Widok = {
223 naglowek: stan ? naglowek(stan) : null,
224 sekcja: stan
225 ? bezNaglowka(stan)
226 : p
227 ? `Plik ${s.stan} nie ma żadnej sekcji „## Stan”.`
228 : `Brak pliku stanu ${s.stan} — ta sesja nie zostawiła jeszcze sekcji „## Stan”.`,
229 brief: brief(stan, s.skrot, cwd),
230 sekcje: p?.stany ?? 0,
231 sciezka: s.stan,
232 }
233 await update($, widok, () => w)
234 return w
235}
236
237/** Jedyny zapis modu na dysk: <sid>-skrot.md obok pliku stanu. null, gdy nie ma czego skracać. */
238async function zapiszSkrot($: EngineInterface): Promise<{ sciezka: string; bajty: number; przycieto: boolean } | null> {
239 const s = await sciezki($)
240 const p = await czytaj($, s.stan)
241 if (!p?.stan) return null
242 const teraz = await $.clock.now()
243 const { tekst, przycieto } = zbudujSkrot(p, `# Skrót stanu sesji ${s.sid} — zapisany ${new Date(teraz).toISOString()}`, s.stan)
244 await $.fs.write(s.skrot, tekst)
245 return { sciezka: s.skrot, bajty: bajty(tekst), przycieto }
246}
247
248/** Klik „Przekaż do nowej sesji”: skrót na dysk, karta start_session; gdy serwer nie odpowie — brief do promptu i schowka. */
249async function przekaz($: EngineInterface, press: UiPressArgument): Promise<void> {
250 const w = await zbudujWidok($)
251 const skrot = await zapiszSkrot($).catch(() => null) // odmowa zapisu nie blokuje karty ani zapasu
252 const [sid, cwd, n, s, teraz] = await Promise.all([$.session.id(), $.session.cwd(), read($, kompaktacje), read($, start), $.clock.now()])
253 const ile = `${n} ${odmiana(n, 'kompaktacja', 'kompaktacje', 'kompaktacji')}`
254 const background =
255 `Przekazanie pałeczki (mod kanon-sztafeta) z sesji ${sid} w ${cwd}: ${ile}, wiek ${s === null ? 'nieznany' : czasTrwania(teraz - s)}. ` +
256 `Plik stanu ${w.sciezka} ma ${w.sekcje} sekcji „## Stan”; ${skrot ? `skrót (≤ 8 KB): ${skrot.sciezka}` : 'skrótu nie ma (brak sekcji „## Stan” albo zapis się nie powiódł)'}. ` +
257 'Zacznij od wczytania skrótu, potem realizuj punkty „dalej” z briefu; decyzje właściciela z briefu obowiązują.'
258 try {
259 const r = await $.mcp.call('ccd_session', 'start_session', {
260 initiation: 'own_initiative',
261 title: skroc(`Sztafeta z sesji ${sid.slice(0, 8)} (${ile})`, 59),
262 prompt: w.brief,
263 background,
264 context: 'fresh',
265 })
266 const tekst = r.content.map(c => c.text ?? '').join('')
267 if (r.isError || /^Error\b/.test(tekst)) throw new Error(skroc(tekst, 160))
268 $.ui.toast('Sztafeta: karta nowej sesji gotowa — uruchom ją w aplikacji (brief i skrót już w niej są)', { timeoutMs: 10_000 })
269 } catch (err) {
270 const powod = err instanceof Error ? err.message : String(err)
271 const [f, c] = await Promise.all([
272 $.prompt.fill({ text: w.brief, mode: 'append' }).catch(() => ({ isFilled: false })),
273 $.ui.copy({ text: w.brief, surface: press.surface }).catch(() => ({ isCopied: false })),
274 ])
275 const gdzie = [f.isFilled ? 'w oknie promptu' : '', c.isCopied ? 'w schowku' : ''].filter(Boolean).join(' i ') || 'tylko w panelu /sztafeta'
276 $.ui.toast(`Sztafeta: start_session nie zadziałał (${skroc(powod, 100)}) — brief ${gdzie}; wklej go do nowej sesji`, { timeoutMs: 15_000 })
277 }
278}
279
280async function pokaz($: EngineInterface): Promise<void> {
281 await zbudujWidok($)
282 const o = await $.ui.open({ id: PANE, title: 'Sztafeta', focus: true })
283 if (!o.isPlaced) $.ui.toast(`Sztafeta: panel czeka na miejsce (${o.reason}) — /sztafeta pokaże treść w transkrypcie`, { timeoutMs: 8_000 })
284}
285
286const zglos = ($: EngineInterface, co: string) => (err: unknown) =>
287 $.ui.toast(`Sztafeta: ${co} nie powiodło się (${skroc(err instanceof Error ? err.message : String(err), 120)})`, { timeoutMs: 10_000 })
288
289export const register: Register = on => {
290 on('session.start', async ($, e, next) => {
291 await $.command.register({
292 name: 'sztafeta',
293 description: 'Sztafeta: ostatnia sekcja „## Stan” tej sesji i brief dla nowej; „skrot” zapisuje ~/.claude/kanon/stan/<sid>-skrot.md (≤ 8 KB)',
294 argumentHint: '[skrot]',
295 })
296 const r = await next(e)
297 if ((await read($, start)) === null) {
298 const z = await $.session.usage().then(u => liczba(u.startedAt), () => null)
299 const teraz = z ?? (await $.clock.now())
300 await update($, start, () => teraz)
301 }
302 await odswiezPlik($).catch(() => undefined)
303 $.clock.every(CO_ILE_TYK, () => {
304 update($, tyk, t => t + 1).catch(() => undefined)
305 })
306 return r
307 }).catch(($, e, next) => next(e))
308
309 on('session.end', async ($, e, next) => {
310 const r = await next(e)
311 if (e.reason === 'clear' || e.reason === 'resume') {
312 // Proces trwa pod nowym id bez session.start — liczniki tej rozmowy nie mogą przejść na świeżą.
313 await Promise.all([
314 update($, kompaktacje, () => 0),
315 update($, start, () => null),
316 update($, kontekst, () => null),
317 update($, plik, () => null),
318 update($, widok, () => null),
319 ]).catch(() => undefined)
320 }
321 return r
322 }).catch(($, e, next) => next(e))
323
324 on('session.compact', async ($, e, next) => {
325 const r = await next(e)
326 if (!e.agentId && e.trigger !== 'precompute' && r.skip === undefined) await update($, kompaktacje, n => n + 1).catch(() => undefined)
327 await odswiezPlik($).catch(() => undefined)
328 return r
329 }).catch(($, e, next) => next(e))
330
331 on('turn.complete', async ($, e, next) => {
332 const r = await next(e)
333 if (e.agentId) return r // pasek dotyczy głównego wątku
334 await $.session
335 .usage()
336 .then(u => zastosujKontekst($, u.context))
337 .catch(() => undefined)
338 await odswiezPlik($).catch(() => undefined)
339 return r
340 }).catch(($, e, next) => next(e))
341
342 on('session.measure', async ($, e, next) => {
343 const r = await next(e)
344 await zastosujKontekst($, e.context).catch(() => undefined)
345 return r
346 }).catch(($, e, next) => next(e))
347
348 on('command.run', { command: 'sztafeta' }, async ($, e) => {
349 if (e.args.trim().toLowerCase() === 'skrot') {
350 const r = await zapiszSkrot($)
351 return {
352 text: r
353 ? `Skrót zapisany: ${r.sciezka} (${r.bajty} B${r.przycieto ? ', przycięto do 8 KB' : ''}). Nowa sesja: „wczytaj ${r.sciezka}”.`
354 : `Brak sekcji „## Stan” w pliku stanu tej sesji — nie ma czego skracać.`,
355 }
356 }
357 const w = await zbudujWidok($)
358 const o = await $.ui.open({ id: PANE, title: 'Sztafeta', focus: true }).catch(() => ({ isPlaced: false as const, reason: 'błąd otwarcia' }))
359 const linia = w.naglowek ? `Sztafeta: „${w.naglowek}” (ostatnia z ${w.sekcje}); brief w panelu, skrót zapisze przycisk albo /sztafeta skrot.` : `Sztafeta: ${w.sekcja}`
360 return { text: o.isPlaced ? linia : `${linia}\n\n${w.sekcja}\n\n— brief dla nowej sesji —\n${w.brief}` }
361 }).catch((_$, _e, next) => ({ text: `Sztafeta: /sztafeta nie powiodło się (${next.error.kind}${next.error.message ? `: ${skroc(next.error.message, 120)}` : ''})` }))
362
363 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
364 if (e.props.hasSurvey || e.props.maxRows < 1) return next(e)
365 const [n, s, c, p, teraz] = await Promise.all([read($, kompaktacje), read($, start), read($, kontekst), read($, plik), $.clock.now()])
366 await read($, tyk) // sam odczyt wiąże pasek z timerem: co 5 min wiek sesji rysuje się na nowo
367 const wiek = s === null ? 0 : teraz - s
368 const zaWarn = c !== null && c.tokens >= c.warn
369 if (n < 1 && wiek < PROG_WIEKU && !zaWarn) return next(e)
370
371 const { Box, Button, Text } = $.ui.resolve(e)
372 const stan =
373 p === null ? 'stan: brak pliku' : p.sekcje === 0 ? `stan: plik bez „## Stan” (${temu(teraz - p.mtimeMs)})` : `stan: ${p.czas} (${temu(teraz - p.mtimeMs)}, „## Stan” #${p.sekcje})`
374 const czesci = [`${n} ${odmiana(n, 'kompaktacja', 'kompaktacje', 'kompaktacji')}`, `sesja ${czasTrwania(wiek)}`, stan]
375 if (zaWarn && c) czesci.push(`kontekst ${k(c.tokens)} ≥ WARN ${k(c.warn)}`)
376 // Pasmo rysuje JEDEN hook (d.ts: „draws a tree, or passes; one instance”): to, co narysowałby plugin niżej w łańcuchu
377 // (np. kanon-skrzynka), idzie pod paskiem zamiast zniknąć. Błąd niżej nie gasi paska. Przy maxRows < 2 pasek zostaje sam: drzewo
378 // wyższe niż maxRows przewija się w oknie bodyRows = maxRows − wiersz „n more”, więc stos w jednym wierszu byłby niewidoczny.
379 const nizej = e.props.maxRows >= 2 ? await next(e).catch(() => undefined) : undefined
380 return (
381 <Box flexDirection="column">
382 <Box gap={1}>
383 <Text color="warning">⟳ sztafeta:</Text>
384 <Text>{czesci.join(' · ')}</Text>
385 <Button key="przekaz" label="Przekaż do nowej sesji" variant="primary" onPress={press => void przekaz($, press).catch(zglos($, 'przekazanie'))} />
386 <Button key="pokaz" label="Pokaż „dalej”" onPress={() => void pokaz($).catch(zglos($, 'panel'))} />
387 </Box>
388 {nizej}
389 </Box>
390 )
391 }).catch(($, e, next) => next(e))
392
393 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
394 const w = await read($, widok)
395 if (w === null) return next(e)
396 const { Box, Button, Text } = $.ui.resolve(e)
397 return (
398 <Box flexDirection="column" gap={1}>
399 <Text bold>{w.naglowek ?? 'Brak sekcji „## Stan”'}</Text>
400 <Text>{w.sekcja}</Text>
401 <Text bold>Brief dla nowej sesji</Text>
402 <Text>{w.brief}</Text>
403 <Box gap={1}>
404 <Button key="przekaz" label="Przekaż do nowej sesji" variant="primary" onPress={press => void przekaz($, press).catch(zglos($, 'przekazanie'))} />
405 <Button key="zamknij" label="Zamknij" role="dismiss" onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)} />
406 </Box>
407 </Box>
408 )
409 }).catch(($, e, next) => next(e))
410}
411types/index.d.ts 45 lines1// Kontrakt stanu modu kanon-sztafeta (odczyt w $.state sesji; po reloadzie modułu wartości zostają).
2
3/** Ostatni odczyt kontekstu głównego wątku i próg WARN hooka kanon-budzet (ta sama formuła co kanon-pasek-budzetu). */
4export type Kontekst = {
5 tokens: number
6 /** KANON_CTX_WARN albo okno budżetu − 80k (okno ≥ 200k; poniżej − 40k). */
7 warn: number
8}
9
10/** Plik stanu `~/.claude/kanon/stan/<session_id>.md` — tylko metadane do paska, bez treści. */
11export type Plik = {
12 mtimeMs: number
13 /** Liczba sekcji „## Stan” w pliku. */
14 sekcje: number
15 /** Godzina z nagłówka ostatniej sekcji („23:37”), a gdy nagłówek jej nie ma — z mtime. */
16 czas: string
17}
18
19/** Treść panelu /sztafeta: ostatnia sekcja „## Stan” i brief dla nowej sesji (budowane na żądanie, nie co turę). */
20export type Widok = {
21 /** Linia nagłówka ostatniej sekcji „## Stan”; null, gdy pliku albo sekcji nie ma. */
22 naglowek: string | null
23 /** Treść sekcji bez nagłówka albo komunikat, czemu jej nie ma. */
24 sekcja: string
25 brief: string
26 sekcje: number
27 sciezka: string
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'kanon-sztafeta': {
33 /** Kompaktacje głównego wątku w tej sesji (bez `precompute`, bez pominiętych). */
34 kompaktacje: number
35 /** Początek sesji w ms (`$.session.usage().startedAt`, w odwodzie zegar przy session.start); null przed pierwszym startem. */
36 start: number | null
37 kontekst: Kontekst | null
38 plik: Plik | null
39 widok: Widok | null
40 /** Licznik timera (co 5 min) — sam odczyt przy rysowaniu odświeża wiek sesji na pasku. */
41 tyk: number
42 }
43 }
44}
45