SLOPSHOPPER

kanon-skrzynka

Skrzynka nieprzeczytanych raportów i stanów: na starcie sesji (i co 10 min) liczy raporty WATCH (≤ 14 dni, werdykt), USAGE (≤ 14 dni, koszt i udział 2 dni, 🔴…

panebandcommandtoastprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kanon-skrzynka
│ ┃ Skrzynka ✕ › fix the failing auth test and add an audit log call │ ┃ Skrzynka — 0 do przeczytania │ ┃ Nic do przeczytania: raporty z 14 dni i ⏺ Read(src/auth.ts) │ ┃ stany innych sesji z 7 dni są przeczytane ⎿ Read 6 lines │ ┃ albo ich nie ma. ⏺ Update(src/auth.ts) │ ┃ o: odśwież [ Zamknij ] ⎿ Added 2 lines, removed 1 line │ ┃ 0 do przeczytania · skan 08:53, co 10 min ·… ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skrzynka │ ⎿ kanon-skrzynka: Skrzynka: 0 do przeczytania. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skrzynka
Skrzynka — 0 do przeczytania Nic do przeczytania: raporty z 14 dni i stany innych sesji z 7 dni są przeczytane albo ich nie ma. o: odśwież [ Zamknij ] 0 do przeczytania · skan 08:53, co 10 min · raporty ≤ 14 dn…
README

kanon-skrzynka v0.1

Skrzynka nieprzeczytanych raportów i stanów. Na starcie sesji (i co 10 min) liczy, czego właściciel jeszcze nie przeczytał spod ~/.claude/kanon/, i rysuje JEDNĄ linię nad promptem tylko wtedy, gdy coś jest:

📬 3 do przeczytania: WATCH-10-16 (ROZWAŻ) · USAGE-10-15 (2 dni 19 %) · stan 9f9dcef8 (16.10)   /skrzynka

Karta: ~/Developer/claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §6.1 (dowód z logów: raporty WATCH z werdyktem „ROZWAŻ”, USAGE i pliki stanu, których żadna sesja nie czyta). Zero modelu, zero sieci, zero zapisu plików — liczenie jest deterministyczne i darmowe, a wynik idzie do właściciela, nie do kontekstu modelu.

Źródła (czytane tylko po nagłówki)

ŹródłoPlikCo pokazujePróg wieku (mtime)
watchwatch/WATCH-<data>.mdsłowo po „aktualizacja kanonu” z linii WERDYKT: … (ROZWAŻ / NIE / TAK); raport bez tej linii → „bez werdyktu”14 dni
usageusage/USAGE-tydzien-<data>.mdz linii okno 7 dni od …; koszt-list $X; ostatnie 2 dni $Y = Z%: koszt, 2 dni i Z; 🔴 gdy Z > 4014 dni
stanstan/<sid>.md innych sesji (własny sid z $.session.id() pominięty) z sekcją ## Stansid (8 znaków), data mtime i SAMA linia nagłówka ostatniej sekcji ## Stan7 dni

Treść pliku stanu zawiera nazwy klientów — nie trafia do panelu, paska, $.store ani odpowiedzi komendy (test sprawdza to strażnikiem TAJNE-SLOWO w zmyślonym pliku). $.fs.read nie czyta fragmentu: cały tekst wchodzi do pamięci modułu na czas parsowania i wychodzi z niej sam nagłówek.

Użycie

  • /skrzynka — skan i panel z listą: [źródło] nazwa · DD.MM HH:MM · nagłówek, przy każdej pozycji Wczytaj i Przeczytane, w stopce Wszystkie przeczytane, odśwież (hotkey o), Zamknij (Esc). Gdy terminal jest za wąski na panel, odpowiedź komendy niesie listę (nagłówki, bez treści).
  • Wczytaj zamyka panel i wstawia wczytaj <pełna ścieżka> do pola promptu ($.prompt.fill, tryb replace). Enter naciska właściciel — mod nigdy nie wysyła polecenia. Odmowa pola (np. prompt zajęty) → panel wraca, toast z tekstem do wpisania.
  • Przeczytane / Wszystkie przeczytane → $.store skrzynka:<pełna ścieżka> = { kiedy, sid }. $.store jest trwały między sesjami (d.ts: „kept between sessions”); czy sesje biegnące równolegle widzą cudze oznaczenie od razu — niesprawdzone. Plik zmieniony PO oznaczeniu (mtime > kiedy: sesja dopisała sekcję ## Stan, kanon-watch nadpisał raport dnia) wraca do skrzynki; oznaczenie zostaje w store (kasuje je /skrzynka wyczysc). Klucze nie są przycinane (v0.1): ~100 B na plik, limit store 4 MiB jest daleko.
  • Decyzja D6 (2026-10-17): polecenie wczytaj <ścieżka pozycji> zaobserwowane przez prompt.submit liczy się jako przeczytane (obok przycisku). Obserwator zawsze przepuszcza next(e) bez zmian (nigdy drop), oznacza bez await (Enter nie czeka na kolejkę skanów) i bierze tylko polecenia właściciela: origin composer (Enter przy prompcie) albo bridge (Remote Control), bez isMeta — nie notyfikacje, peer, harmonogram, SDK ani inne pluginy. Wzorzec: nazwa pliku pozycji jako koniec członu tuż po „wczytaj” (pełna ścieżka, z ~ albo sama nazwa, z .md lub bez, przecinek po dozwolony); „wczytaj plan i porównaj z WATCH-…” nie oznacza, <sid>-skrot.md nie oznacza, wczytaj A i B oznacza tylko A. Poza tym promptów nie czyta.
  • /skrzynka wyczysc kasuje klucze skrzynka:* (wszystko wraca do skrzynki), /skrzynka zamknij zamyka panel.
  • Pasek: nagłówek z liczbą, potem najwyżej 3 pozycje i „+N”, przycięte do szerokości (bodyColumns minus 12 komórek na przycisk /skrzynka); 🔴 idzie na początek paska (sort stabilny; w panelu kolejność watch → usage → stan, każde od najnowszego); milczy przy ankiecie (hasSurvey) i maxRows < 1. Kolor warning, gdy któraś pozycja USAGE ma 🔴.
  • Pasmo AbovePrompt rysuje JEDEN hook (d.ts: „draws a tree, or passes; one instance”). Skrzynka składa pod swoją linią to, co narysowałby plugin niżej w łańcuchu (next(e)), bo kanon-pasek-budzetu i kanon-sztafeta rysują własne drzewa bez next. Gdy skrzynka jest w łańcuchu NIŻEJ niż one, nie narysuje się wcale — kolejność ładowania do sprawdzenia na żywo (Niesprawdzone).

Odstępstwa od karty §6.1

  • Karta: „USAGE — linia 5”. Realne raporty mają w linii 5 „```”, a podsumowanie w linii 6; starsze raporty WATCH (bez oceny LLM) mają w linii 5 „## Zmiany” zamiast WERDYKT. Parsery szukają w pierwszych 12 liniach; brak linii → etykieta „bez werdyktu” / „bez podsumowania” zamiast pominięcia raportu (raport nadal jest do przeczytania).
  • Plik stanu = tylko pełny UUID <sid>.md; <sid>-skrot.md (zapis kanon-sztafety) i pliki bez sekcji ## Stan (sam zapis hooka kanon-stan) nie są pozycjami.
  • Wiek z mtimeMs wpisu $.fs.list (bez $.fs.stat); data WATCH/USAGE z nazwy pliku, data stanu z mtime.
  • Oznaczenie „przeczytane” nie jest wieczne: plik zmieniony po oznaczeniu (mtime > kiedy) wraca (karta mówi tylko o kluczu skrzynka:<ścieżka>; bez tej reguły stan żywej sesji z nową sekcją ## Stan już by nie wrócił).
  • D6 zawężone do poleceń właściciela i wzorca „wczytaj <ścieżka kończąca się nazwą pliku>” (karta: „tekst zawiera wczytaj i ścieżkę”).
  • Skan ze startu biegnie bez await (pierwszy session.start silnik awaituje; zawieszony odczyt nie może wstrzymać sesji) — pasek pojawia się po zapisie $.state, ułamek sekundy po starcie.
  • Na tym Macu pierwszy skan pokaże kilkadziesiąt pozycji (13 WATCH, 3 USAGE i ~30 stanów z ostatnich 7 dni — stan z 2026-10-17): „Wszystkie przeczytane” zeruje skrzynkę jednym przyciskiem; zawężenie progów to decyzja właściciela po próbie na żywo.

Bramki

Z claude plugin validate . (2.1.302 i 2.1.299):

./register.tsx hooks: session.start, prompt.submit, command.run{command=skrzynka}, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=skrzynka}
./register.tsx answers its own command: command.run{command=skrzynka}
./register.tsx gating hook with .catch: prompt.submit
./register.tsx calls: $.clock.every, $.clock.now (via oznaczTeraz, skanujTeraz), $.command.register, $.env.get (via skanujTeraz), $.fs.list (via listuj), $.fs.read (via czytaj), $.prompt.fill (via wczytaj), $.session.id (via oznaczTeraz, skanujTeraz), $.state.get, $.state.set, $.store.delete, $.store.get (via zmienionyPoOznaczeniu), $.store.keys, $.store.set (via oznaczTeraz), $.ui.close, $.ui.open (via otworzPanel), $.ui.resolve, $.ui.toast (via oznaczToastem, wczytaj)
./register.tsx env writes: nothing
./register.tsx env reads: HOME
./register.tsx state writes: kanon-skrzynka.skrzynka
./register.tsx state reads: kanon-skrzynka.skrzynka
  • Zero $.model, $.http, $.process.*, $.fs.write, tool.check. Jedyny stan trwały: $.store (klucze skrzynka:<ścieżka>, wartość { kiedy: ISO, sid }); stan sesji: $.state kanon-skrzynka.skrzynka (types/index.d.ts).
  • Każdy hook ma .catch (fail-open): błąd modu nie zatrzymuje startu sesji, polecenia ani rysowania; command.run odpowiada tekstem błędu. Każdy odczyt katalogu/pliku w try/catch: brak katalogu, odmowa, plik > 4 MiB = brak pozycji, bez toasta.
  • Dno testów odgrywa odmowy nounów silnika przez { deny } (ENOENT katalogu/pliku, EACCES, session.id), nie przez throw — rzucający hook testu silnik pomija i raportuje jako „skipped”, co nie jest tym samym, co odmowa.
  • Skany i oznaczenia idą przez jedną kolejkę modułu, więc skan nie przywróci pozycji oznaczonej w jego trakcie. Jeden timer modułu: session.start (odpala się też przy enable i respawnie workera) kasuje poprzedni $.clock.every przed założeniem nowego.

Testy

tests/skrzynka.test.ts — 18 testów, dysk w pamięci pod HOME=/tmp/dom-testu ze ZMYŚLONYMI raportami i wymyślonymi UUID sesji (bez nazw klientów, bez identyfikatorów realnych sesji): parsery (werdykt, podsumowanie, nagłówek stanu), liniaPaska (3 pozycje + „+N”, przycięcie), skan z trzech źródeł z progami 14/7 dni, własny sid, skrót i plik bez ## Stan pominięte, panel i pasek bez treści stanu; 🔴 na granicy 40 %; progi wieku na granicy; „Przeczytane” → store, skan z timera nie przywraca, wyczysc przywraca; D6 w prompt.submit (pełna ścieżka, ~, sama nazwa; polecenie przechodzi niezmienione; bez „wczytaj” nic); „Wczytaj” → fill replace bez store i bez wysłania, odmowa → panel wraca; D6 negatywy (nazwa dalej w zdaniu, <sid>-skrot.md, origin peer/sdk, isMeta; bridge oznacza); plik zmieniony po oznaczeniu wraca; „Wszystkie przeczytane” i zamknij; brak katalogów → bez paska, bez toastów; przycięcie paska na 66/40 kolumnach, ankieta i maxRows 0; fail-open (EACCES jednego pliku; $.session.id odmawia; $.store.set odmawia — polecenie przechodzi, przycisk daje toast); timer 10 min i odpowiedź z listą, gdy panel bez miejsca. Strażnik TAJNE-SLOWO w teście głównym obejmuje wszystkie wyjścia: drzewo paska i panelu, toasty, prompt.fill, odpowiedzi komend, wartości $.store i każdy zapis $.state (hook dna state.set).

Binarkavalidatetesttsc strict
CLI ~/.local/bin/claude 2.1.302✔18/18✔ (node ~/Developer/stelmach/wishlist/node_modules/typescript/bin/tsc -p .)
desktop 2.1.299 (…/claude.app/Contents/MacOS/claude)✔18/18—

Recenzja

Jedna runda recenzent [S5.5HI] (2026-10-17): 0 🔴, 7 🟡, 2 🔵, 3 ❓ — wszystkie zamknięte w kodzie/testach/README:

  • 🟡 🔴 USAGE tonęło w „+N” za WATCH → alarm na początek paska (sort stabilny).
  • 🟡 await skanuj wstrzymywał session.start → skan bez await; 🟡 obserwator await oznacz wstrzymywał Enter → bez await.
  • 🟡 matcher D6 za luźny (zdanie z nazwą dalej, -skrot.md, origin pluginu) → wzorzec „wczytaj <ścieżka…nazwa>”, origin composer/bridge, bez isMeta; test negatywów.
  • 🟡 AbovePrompt bez next(e) zasłaniał pasek budżetu/sztafety → złożenie w kolumnę; kolejność łańcucha do sprawdzenia na żywo.
  • 🟡 strażnik treści tylko na panelu/store/liście → wszystkie wyjścia + zapisy $.state; 🟡 ścieżka błędu obserwatora nietestowana → test z odmową $.store.set.
  • ❓ oznaczenie wieczne mimo nowych sekcji → reguła mtime > kiedy; ❓ timer bez cancel przy ponownym session.start → jeden timer modułu; ❓ „znika w pozostałych sesjach” nieudowodnione → przeniesione do Niesprawdzone.
  • 🔵 fikstury z realnymi UUID sesji → wymyślone; 🔵 tytuł testu obiecywał więcej niż asercje → skrócony.

Sprawdzone na żywo / niesprawdzone

  • Niesprawdzone (wymaga sesji desktop z hot-reloadem albo --plugin-dir): rysowanie paska i panelu w prawdziwym terminalu, $.prompt.fill z zamkniętego panelu, hotkey o, kolory warning/error, panel przy kilkudziesięciu pozycjach (rows = min(14, n + 4); przewijanie dłuższego drzewa jest po stronie silnika — SiteScroll w d.ts — ale na żywo niesprawdzone).
  • Widoczność oznaczeń między sesjami biegnącymi równolegle ($.store trwały, ale czy odświeżany między procesami — nie wiadomo).
  • Kolejność łańcucha hooków AbovePrompt (skrzynka vs kanon-pasek-budzetu vs kanon-sztafeta): skrzynka składa next(e), tamte nie.
  • Timer $.clock.every żyje do reloadu modułu; session.start po reloadzie zakłada go od nowa (poprzedni kasowany).

Uruchomienie i rollback

  • Próba: kopia do ~/.claude/dev-mods/<session_id>/kanon-skrzynka/ (hot-reload za zgodą właściciela) albo claude --plugin-dir ~/Developer/claude-mods/kanon-skrzynka.
  • Stabilnie: awans ff-only do claude-mods-stabilne i wpis w CLAUDE_CODE_PLUGIN_DIRS — decyzja właściciela (README repo).
  • Rollback: usunąć wpis z CLAUDE_CODE_PLUGIN_DIRS (albo folder z dev-mods); klucze skrzynka:* w $.store zostają — kasuje je /skrzynka wyczysc przed wyłączeniem albo zostają bez skutku (nikt ich nie czyta).
Source 2 files
hooks/register.tsx 410 lines
1// kanon-skrzynka — nieprzeczytane raporty i stany na start sesji (mod Claude Code, API function hooks 2.1.299; early access). v0.1.
2// Plan: ~/Developer/claude-kanon/claude-code/mods/PLAN-mody-2026-10-16-z-logow.md §6.1. Dowód z logów: 7 raportów WATCH z werdyktem
3// „ROZWAŻ”, 4 raporty USAGE i 51 plików stanu, których żadna sesja nie czyta (0 wystąpień „WATCH-” w 190 poleceniach). Odpowiednik modu
4// „You Should Know” z filmu O'Briena, ale BEZ modelu: liczenie jest deterministyczne i darmowe, a wynik idzie do właściciela
5// (jedna linia nad promptem), nie do kontekstu modelu (hook SessionStart z additionalContext kosztowałby ~0,5k tokenów w każdym żądaniu).
6// Trzy źródła spod ~/.claude/kanon/ (HOME z `$.env.get`), czytane TYLKO po nagłówki:
7//   watch/WATCH-<data>.md — linia „**WERDYKT: aktualizacja kanonu <SŁOWO> — …**” (linia 5 raportu; starsze raporty bez oceny LLM
8//     mają tam „## Zmiany” → „bez werdyktu”); pasek: `WATCH-10-16 (ROZWAŻ)`; próg 14 dni (mtime);
9//   usage/USAGE-tydzien-<data>.md — linia „okno 7 dni od …; plików N; koszt-list $X; ostatnie 2 dni $Y = Z%” (linia 6, pod ```);
10//     pasek: `USAGE-10-15 (2 dni 19 %)`, `🔴` gdy Z > 40 (próg raportu tygodniowego); próg 14 dni;
11//   stan/<sid>.md — pliki stanu INNYCH sesji (własny sid z `$.session.id()` pominięty; `<sid>-skrot.md` kanon-sztafety nie jest
12//     plikiem stanu) z sekcją „## Stan” i mtime ≤ 7 dni; pasek: `stan 9f9dcef8 (16.10)`; w panelu TYLKO linia nagłówka ostatniej
13//     sekcji „## Stan” — treść stanu zawiera nazwy klientów i NIE trafia ani do panelu, ani do `$.store`, ani do odpowiedzi komendy.
14// `$.fs.read` nie czyta fragmentu pliku: cały tekst wchodzi do pamięci modułu na czas parsowania i wychodzi z niej sam nagłówek.
15// Skan: `session.start`, co 10 min (`$.clock.every`), `/skrzynka`, „odśwież”. Pasek (AbovePrompt) tylko, gdy są pozycje: najwyżej
16// 3 pozycje + „+N”, przycięte do szerokości. `/skrzynka` → pane z listą (źródło, nazwa, data, nagłówek) i przyciskami: „Wczytaj” →
17// `$.prompt.fill` (replace) z „wczytaj <pełna ścieżka>” — Enter naciska właściciel; „Przeczytane” → `$.store` `skrzynka:<ścieżka>`
18// = `{ kiedy, sid }`; „Wszystkie przeczytane”. Plik zmieniony PO oznaczeniu (mtime > kiedy: sesja dopisała sekcję, kanon-watch nadpisał
19// raport dnia) wraca do skrzynki. `/skrzynka wyczysc` kasuje klucze `skrzynka:*`. Decyzja D6 (2026-10-17): polecenie
20// „wczytaj <ścieżka pozycji>” zaobserwowane przez `prompt.submit` (zawsze `next(e)` bez zmian, nigdy `drop`) oznacza pozycję jako
21// przeczytaną — obserwator bierze tylko polecenia właściciela (origin `composer`/`bridge`, bez `isMeta`) i sprawdza wzorzec „wczytaj
22// <ścieżka kończąca się nazwą pliku pozycji z bieżącej listy>”; poza tym promptów nie czyta. Na pasku 🔴 idzie na początek.
23// Bramki: zero `$.model`, `$.http`, `$.process`, `tool.check`, bez zapisu plików; jedyny stan to `$.store` (klucze `skrzynka:*`,
24// wspólne dla sesji na Macu) i `$.state`. Każdy odczyt pliku w try/catch (brak katalogu lub pliku = brak pozycji, bez błędu);
25// każdy hook z `.catch` (fail-open: błąd modu nie zatrzymuje startu sesji, polecenia ani rysowania).
26import { atom, read, update } from 'claude-code'
27import type { EngineInterface, FsEntry, PromptSubmitInput, Register, Timer } from 'claude-code'
28
29import type { Pozycja, Skrzynka } from '../types'
30
31const PANE = 'skrzynka'
32const KLUCZ = 'skrzynka:'
33const CO_ILE = 10 * 60_000 // skan co 10 min
34const DOBA = 86_400_000
35export const PROG_RAPORTOW = 14 * DOBA // WATCH i USAGE starsze niż 14 dni (mtime) pomijane
36export const PROG_STANOW = 7 * DOBA // pliki stanu starsze niż 7 dni pomijane
37export const PROG_ALARMU = 40 // %; 🔴 gdy ostatnie 2 dni > 40 % okna 7 dni (próg raportu tygodniowego)
38export const MAX_NA_PASKU = 3 // pozycji na pasku; reszta jako „+N”
39const LINII_NAGLOWKA = 12 // tyle pierwszych linii raportu przeszukuje parser (WERDYKT = linia 5, podsumowanie USAGE = linia 6)
40const SZER_PRZYCISKU = 12 // miejsce na przycisk „/skrzynka” i odstęp obok linii paska
41const MAX_WIERSZY_PANELU = 14
42
43// Skaner walidatora wymaga literałów plugin/key w każdym atomie (nie stałej) — stąd powtórzona nazwa.
44const PUSTA: Skrzynka = { pozycje: [], odczyt: null, blad: null }
45const skrzynka = atom({ plugin: 'kanon-skrzynka', key: 'skrzynka' } as const, PUSTA)
46
47// ---------- parsery nagłówków (czyste funkcje, bez $; testy importują je stąd) ----------
48const WATCH = /^WATCH-(\d{4})-(\d{2})-(\d{2})\.md$/
49const USAGE = /^USAGE-tydzien-(\d{4})-(\d{2})-(\d{2})\.md$/
50const STAN = /^([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.md$/i
51const WERDYKT = /^\*{0,2}WERDYKT:\s*aktualizacja kanonu\s+(\S+)(.*)$/
52const PODSUMOWANIE = /^okno 7 dni od .*?koszt-list\s+(\$[\d\s,.]*\d);\s*ostatnie 2 dni\s+(\$[\d\s,.]*\d)\s*=\s*(\d+(?:[.,]\d+)?)\s*%/
53const NAGLOWEK_STAN = /^## Stan\b/
54
55const naglowki = (tekst: string) => tekst.split('\n', LINII_NAGLOWKA)
56const bezGwiazdek = (l: string) => l.replace(/^\*+|\*+$/g, '').trim()
57
58/** Linia WERDYKT raportu WATCH: słowo po „kanonu” (ROZWAŻ / NIE / TAK, bez gwiazdek i interpunkcji) i cała linia bez gwiazdek; null, gdy jej nie ma. */
59export function werdykt(tekst: string): { slowo: string; linia: string } | null {
60  for (const l of naglowki(tekst)) {
61    const m = WERDYKT.exec(l.trim())
62    if (m?.[1]) return { slowo: m[1].replace(/[*.,:;]+$/, ''), linia: bezGwiazdek(l) }
63  }
64  return null
65}
66
67/** Linia podsumowania raportu USAGE: koszt-list, koszt 2 dni i udział Z (%); null, gdy jej nie ma. */
68export function podsumowanie(tekst: string): { koszt: string; dwaDni: string; procent: number } | null {
69  for (const l of naglowki(tekst)) {
70    const m = PODSUMOWANIE.exec(l.trim())
71    if (m?.[1] && m[2] && m[3]) return { koszt: m[1], dwaDni: m[2], procent: Number(m[3].replace(',', '.')) }
72  }
73  return null
74}
75
76/** Nagłówek OSTATNIEJ sekcji „## Stan” pliku stanu (bez „## ”); null, gdy pliku nie dzieli żadna. Treści sekcji nie zwraca. */
77export function naglowekStanu(tekst: string): string | null {
78  let ostatni: string | null = null
79  for (const l of tekst.split('\n')) if (NAGLOWEK_STAN.test(l)) ostatni = l.slice(3).trim()
80  return ostatni
81}
82
83// ---------- tekst ----------
84const pad2 = (n: number) => String(n).padStart(2, '0')
85const dzienMiesiac = (ms: number) => {
86  const d = new Date(ms)
87  return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}`
88}
89const dataCzas = (ms: number) => {
90  const d = new Date(ms)
91  return `${dzienMiesiac(ms)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
92}
93const procent = (p: number) => `${Number.isInteger(p) ? p : p.toFixed(1).replace('.', ',')} %`
94/** Szerokość w komórkach terminala: emoji liczy się za 2. */
95const szer = (s: string) => [...s].reduce((n, ch) => n + (/\p{Extended_Pictographic}/u.test(ch) ? 2 : 1), 0)
96const opisBledu = (err: unknown) => (err instanceof Error ? err.message : String(err))
97
98/** Jedna linia paska: nagłówek z liczbą, potem najwyżej MAX_NA_PASKU pozycji i „+N”, ile zmieści się w `szerokosc` komórek. */
99export function liniaPaska(pozycje: readonly Pozycja[], szerokosc: number): string {
100  if (!pozycje.length) return ''
101  const naglowek = `📬 ${pozycje.length} do przeczytania`
102  // 🔴 na początek (sort stabilny): pasek ma najwyżej MAX_NA_PASKU pozycji, a alarm USAGE nie może utonąć w „+N” za starszymi WATCH
103  const lista = [...pozycje].sort((a, b) => Number(b.alarm) - Number(a.alarm))
104  let linia = naglowek
105  for (let n = 1; n <= Math.min(MAX_NA_PASKU, lista.length); n++) {
106    const reszta = lista.length - n
107    const kandydat = `${naglowek}: ${lista
108      .slice(0, n)
109      .map(p => p.etykieta)
110      .join(' · ')}${reszta > 0 ? ` +${reszta}` : ''}`
111    if (szer(kandydat) > szerokosc) break
112    linia = kandydat
113  }
114  return linia
115}
116
117const ZRODLO: Record<Pozycja['zrodlo'], string> = { watch: 'watch', usage: 'usage', stan: 'stan' }
118/** Wiersz panelu: źródło · nazwa · data · nagłówek (nagłówek, nigdy treść). */
119export const opis = (p: Pozycja) => `[${ZRODLO[p.zrodlo]}] ${p.nazwa} · ${dataCzas(p.mtimeMs)} · ${p.naglowek}`
120
121// ---------- skan ----------
122const sciezki = (home: string) => ({
123  watch: `${home}/.claude/kanon/watch`,
124  usage: `${home}/.claude/kanon/usage`,
125  stan: `${home}/.claude/kanon/stan`,
126})
127
128/** Wpisy katalogu; brak katalogu albo odmowa = pusto (bez błędu). */
129async function listuj($: EngineInterface, katalog: string): Promise<FsEntry[]> {
130  try {
131    return await $.fs.list(katalog)
132  } catch {
133    return []
134  }
135}
136/** Tekst pliku; brak, odmowa albo > 4 MiB (`$.fs.read` odmawia) = null — pozycja bez czytelnego nagłówka jest pomijana. */
137async function czytaj($: EngineInterface, sciezka: string): Promise<string | null> {
138  try {
139    return String(await $.fs.read(sciezka))
140  } catch {
141    return null
142  }
143}
144
145// Kolejka modułu: skany (start, timer, komenda) i oznaczenia idą po kolei — skan nie przywróci pozycji oznaczonej w jego trakcie.
146let kolejka: Promise<unknown> = Promise.resolve()
147const szereguj = <T,>(f: () => Promise<T>): Promise<T> => {
148  const p = kolejka.then(f, f)
149  kolejka = p.catch(() => undefined)
150  return p
151}
152
153const skanuj = ($: EngineInterface): Promise<Skrzynka> => szereguj(() => skanujTeraz($))
154
155/** Oznaczenie „przeczytane” trzyma się ścieżki; plik zmieniony PO oznaczeniu (mtime > `kiedy`) wraca do skrzynki.
156 *  Wartość nieczytelna albo bez `kiedy` = zostaje przeczytany (nie wracają hurtem przy błędzie store). */
157async function zmienionyPoOznaczeniu($: EngineInterface, sciezka: string, mtimeMs: number): Promise<boolean> {
158  try {
159    const z = await $.store.get(KLUCZ + sciezka)
160    const kiedy = typeof z === 'object' && z !== null && 'kiedy' in z && typeof z.kiedy === 'string' ? Date.parse(z.kiedy) : NaN
161    return Number.isFinite(kiedy) && mtimeMs > kiedy
162  } catch {
163    return false
164  }
165}
166
167async function skanujTeraz($: EngineInterface): Promise<Skrzynka> {
168  const teraz = await $.clock.now()
169  try {
170    const [home, sid] = await Promise.all([$.env.get('HOME'), $.session.id()])
171    if (!home) return await update($, skrzynka, s => ({ ...s, odczyt: teraz, blad: 'brak HOME w środowisku' }))
172    const klucze = await $.store.keys().catch((): string[] => [])
173    const przeczytane = new Set(klucze.filter(k => k.startsWith(KLUCZ)).map(k => k.slice(KLUCZ.length)))
174    const d = sciezki(home)
175    const pozycje: Pozycja[] = []
176    const swieze = (wpisy: FsEntry[], wzorzec: RegExp, prog: number) =>
177      wpisy.filter(e => e.kind === 'file' && wzorzec.test(e.name) && e.mtimeMs > 0 && teraz - e.mtimeMs <= prog).sort((a, b) => b.mtimeMs - a.mtimeMs)
178    const przeczytany = async (sciezka: string, mtimeMs: number) => przeczytane.has(sciezka) && !(await zmienionyPoOznaczeniu($, sciezka, mtimeMs))
179
180    for (const e of swieze(await listuj($, d.watch), WATCH, PROG_RAPORTOW)) {
181      const sciezka = `${d.watch}/${e.name}`
182      if (await przeczytany(sciezka, e.mtimeMs)) continue
183      const t = await czytaj($, sciezka)
184      const m = WATCH.exec(e.name)
185      if (t === null || !m) continue
186      const w = werdykt(t)
187      const nazwa = `WATCH-${m[2]}-${m[3]}`
188      pozycje.push({
189        zrodlo: 'watch',
190        id: `watch-${m[1]}-${m[2]}-${m[3]}`,
191        sciezka,
192        plik: e.name.slice(0, -3),
193        nazwa,
194        etykieta: `${nazwa} (${w?.slowo ?? 'bez werdyktu'})`,
195        naglowek: w?.linia ?? 'bez linii WERDYKT (raport bez oceny)',
196        mtimeMs: e.mtimeMs,
197        alarm: false,
198      })
199    }
200    for (const e of swieze(await listuj($, d.usage), USAGE, PROG_RAPORTOW)) {
201      const sciezka = `${d.usage}/${e.name}`
202      if (await przeczytany(sciezka, e.mtimeMs)) continue
203      const t = await czytaj($, sciezka)
204      const m = USAGE.exec(e.name)
205      if (t === null || !m) continue
206      const u = podsumowanie(t)
207      const alarm = u !== null && u.procent > PROG_ALARMU
208      const nazwa = `USAGE-${m[2]}-${m[3]}`
209      pozycje.push({
210        zrodlo: 'usage',
211        id: `usage-${m[1]}-${m[2]}-${m[3]}`,
212        sciezka,
213        plik: e.name.slice(0, -3),
214        nazwa,
215        etykieta: `${alarm ? '🔴 ' : ''}${nazwa} (${u ? `2 dni ${procent(u.procent)}` : 'bez podsumowania'})`,
216        naglowek: u ? `koszt-list ${u.koszt} · ostatnie 2 dni ${u.dwaDni} = ${procent(u.procent)}` : 'bez linii podsumowania',
217        mtimeMs: e.mtimeMs,
218        alarm,
219      })
220    }
221    for (const e of swieze(await listuj($, d.stan), STAN, PROG_STANOW)) {
222      const m = STAN.exec(e.name)
223      const sciezka = `${d.stan}/${e.name}`
224      if (!m?.[1] || e.name === `${sid}.md` || (await przeczytany(sciezka, e.mtimeMs))) continue
225      const t = await czytaj($, sciezka)
226      if (t === null) continue
227      const naglowek = naglowekStanu(t)
228      if (naglowek === null) continue // plik bez sekcji „## Stan” (sam zapis hooka kanon-stan) to nie pozycja
229      const nazwa = `stan ${m[1]}`
230      pozycje.push({
231        zrodlo: 'stan',
232        id: `stan-${m[1]}`,
233        sciezka,
234        plik: e.name.slice(0, -3),
235        nazwa,
236        etykieta: `${nazwa} (${dzienMiesiac(e.mtimeMs)})`,
237        naglowek,
238        mtimeMs: e.mtimeMs,
239        alarm: false,
240      })
241    }
242    return await update($, skrzynka, () => ({ pozycje, odczyt: teraz, blad: null }))
243  } catch (err) {
244    // $.state odrzucone albo awaria rzeczownika: poprzednie pozycje zostają, stopka panelu pokazuje błąd
245    return update($, skrzynka, s => ({ ...s, odczyt: teraz, blad: opisBledu(err) })).catch(() => ({ ...PUSTA, odczyt: teraz, blad: opisBledu(err) }))
246  }
247}
248
249// ---------- oznaczanie ----------
250const oznacz = ($: EngineInterface, lista: readonly Pozycja[]): Promise<void> => szereguj(() => oznaczTeraz($, lista))
251
252/** `skrzynka:<ścieżka>` = `{ kiedy, sid }` dla każdej pozycji; z listy w `$.state` znikają od razu (bez ponownego skanu). */
253async function oznaczTeraz($: EngineInterface, lista: readonly Pozycja[]): Promise<void> {
254  if (!lista.length) return
255  const [sid, teraz] = await Promise.all([$.session.id(), $.clock.now()])
256  const kiedy = new Date(teraz).toISOString()
257  for (const p of lista) await $.store.set(KLUCZ + p.sciezka, { kiedy, sid })
258  const zdjete = new Set(lista.map(p => p.sciezka))
259  await update($, skrzynka, s => ({ ...s, pozycje: s.pozycje.filter(p => !zdjete.has(p.sciezka)) }))
260}
261
262const oznaczToastem = ($: EngineInterface, lista: readonly Pozycja[]) =>
263  oznacz($, lista).catch(err => $.ui.toast(`Skrzynka: nie udało się oznaczyć — ${opisBledu(err)}`, { timeoutMs: 8000 }))
264
265// ---------- panel ----------
266const otworzPanel = async ($: EngineInterface) => {
267  const s = await read($, skrzynka)
268  return $.ui.open({ id: PANE, title: 'Skrzynka', focus: true, closeOnEscape: true, rows: Math.min(MAX_WIERSZY_PANELU, s.pozycje.length + 4) })
269}
270
271/** „Wczytaj”: panel zamknięty PRZED fill (otwarty z `focus` trzyma klawiaturę), potem „wczytaj <ścieżka>” do pola promptu — Enter
272 *  naciska właściciel; dopiero jego polecenie (obserwator `prompt.submit`) oznaczy pozycję. Odmowa → panel wraca, toast z tekstem. */
273async function wczytaj($: EngineInterface, p: Pozycja): Promise<void> {
274  const tekst = `wczytaj ${p.sciezka}`
275  try {
276    await $.ui.close({ id: PANE })
277    const f = await $.prompt.fill({ text: tekst, mode: 'replace' })
278    if (f.isFilled) {
279      $.ui.toast(`Skrzynka: „wczytaj ${p.nazwa}” w polu promptu — Enter wysyła`)
280      return
281    }
282    await otworzPanel($)
283    $.ui.toast(`Skrzynka: pole promptu nie przyjęło tekstu — wpisz sam: ${tekst}`, { timeoutMs: 10_000 })
284  } catch (err) {
285    $.ui.toast(`Skrzynka: błąd — ${opisBledu(err)}; wpisz sam: ${tekst}`, { timeoutMs: 10_000 })
286  }
287}
288
289const godzina = (ms: number) => `${pad2(new Date(ms).getHours())}:${pad2(new Date(ms).getMinutes())}`
290const stopka = (s: Skrzynka) =>
291  `${s.pozycje.length} do przeczytania · skan ${s.odczyt === null ? '—' : godzina(s.odczyt)}, co ${CO_ILE / 60_000} min · raporty ≤ 14 dni, stany innych sesji ≤ 7 dni` +
292  (s.blad ? ` · błąd: ${s.blad}` : '')
293
294// D6: tylko polecenie właściciela (Enter przy prompcie albo Remote Control), nie notyfikacja, peer, harmonogram ani inny plugin.
295const odWlasciciela = (e: PromptSubmitInput) => !e.isMeta && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
296const uciekaj = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
297/** „wczytaj <ścieżka kończąca się nazwą pliku pozycji>[.md]”: nazwa to koniec członu tuż po „wczytaj” (nie gdziekolwiek w zdaniu;
298 *  `<sid>-skrot.md` nie trafia, bo po sid idzie „-skrot”). Wielkość liter bez znaczenia. */
299const wczytany = (tekst: string, p: Pozycja) => new RegExp(`wczytaj\\s+\\S*?${uciekaj(p.plik)}(?:\\.md)?(?=[^\\w-]|$)`, 'i').test(tekst)
300
301// Jeden timer modułu: `session.start` odpala się też przy enable i respawnie workera, więc poprzedni kasujemy przed nowym.
302let timer: Timer | undefined
303
304export const register: Register = on => {
305  on('session.start', async ($, e, next) => {
306    await $.command.register({
307      name: 'skrzynka',
308      description: 'Skrzynka nieprzeczytanych raportów WATCH/USAGE i stanów innych sesji: Wczytaj → „wczytaj <ścieżka>” w polu promptu, Przeczytane → $.store; „wyczysc” kasuje oznaczenia',
309      argumentHint: '[wyczysc|zamknij]',
310    })
311    const r = await next(e)
312    // Bez await: pierwszy session.start jest awaitowany przez silnik, a skan to kilkadziesiąt odczytów — zawieszony odczyt nie może
313    // wstrzymać startu sesji. Pasek przerysuje się sam po zapisie $.state.
314    void skanuj($).catch(() => undefined)
315    timer?.cancel()
316    timer = $.clock.every(CO_ILE, () => void skanuj($).catch(() => undefined))
317    return r
318  }).catch(($, e, next) => next(e))
319
320  // D6: „wczytaj <ścieżka pozycji>” = przeczytane. Obserwator: `next(e)` zawsze z niezmienionym `e`, nigdy `drop`; nie czyta promptów
321  // poza sprawdzeniem słowa „wczytaj” i nazw plików z bieżącej listy.
322  on('prompt.submit', async ($, e, next) => {
323    try {
324      if (odWlasciciela(e) && /wczytaj/i.test(e.text)) {
325        const s = await read($, skrzynka)
326        const trafione = s.pozycje.filter(p => wczytany(e.text, p))
327        // bez await: oznaczenie czeka w kolejce za skanem, a Enter właściciela nie może czekać na nie
328        if (trafione.length) void oznacz($, trafione).catch(() => undefined)
329      }
330    } catch {
331      // obserwator: błąd oznaczenia nie dotyka polecenia
332    }
333    return next(e)
334  }).catch(($, e, next) => next(e))
335
336  // Hook odpowiada za własną komendę; try/catch, bo hook, który się wysypie, silnik pomija (komenda bez odpowiedzi).
337  on('command.run', { command: 'skrzynka' }, async ($, e) => {
338    try {
339      const a = e.args.trim().toLowerCase()
340      if (a === 'wyczysc' || a === 'wyczyść') {
341        const klucze = (await $.store.keys()).filter(k => k.startsWith(KLUCZ))
342        for (const k of klucze) await $.store.delete(k)
343        const s = await skanuj($)
344        return { text: `Skrzynka: skasowano ${klucze.length} oznaczeń „przeczytane”; ${s.pozycje.length} do przeczytania.` }
345      }
346      if (a === 'zamknij') {
347        await $.ui.close({ id: PANE })
348        return { text: 'Skrzynka zamknięta.' }
349      }
350      const s = await skanuj($)
351      const o = await otworzPanel($)
352      if (o.isPlaced) return { text: `Skrzynka: ${s.pozycje.length} do przeczytania${s.blad ? ` (błąd skanu: ${s.blad})` : ''}.` }
353      return { text: [`Skrzynka (panel czeka: ${o.reason}): ${s.pozycje.length} do przeczytania`, ...s.pozycje.map(p => `- ${opis(p)}`)].join('\n') }
354    } catch (err) {
355      return { text: `Skrzynka: błąd modu — ${opisBledu(err)}` }
356    }
357  }).catch(() => ({ text: 'Skrzynka: błąd modu — hook odrzucony' }))
358
359  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360    const s = await read($, skrzynka)
361    if (e.props.hasSurvey || e.props.maxRows < 1 || s.pozycje.length === 0) return next(e)
362    const { Box, Button, Text } = $.ui.resolve(e)
363    const linia = liniaPaska(s.pozycje, e.props.bodyColumns - SZER_PRZYCISKU)
364    // Pasmo rysuje JEDEN hook (d.ts: „draws a tree, or passes; one instance”): co narysowałby plugin niżej w łańcuchu (kanon-pasek-budzetu,
365    // kanon-sztafeta rysują własne drzewa bez `next`), idzie pod linią skrzynki zamiast zniknąć. Błąd niżej nie gasi skrzynki.
366    const nizej = await next(e).catch(() => undefined)
367    return (
368      <Box flexDirection="column">
369        <Box gap={1}>
370          <Text wrap="truncate-end" color={s.pozycje.some(p => p.alarm) ? 'warning' : undefined}>
371            {linia}
372          </Text>
373          <Button key="otworz" plain label="/skrzynka" onPress={() => otworzPanel($)} />
374        </Box>
375        {nizej}
376      </Box>
377    )
378  }).catch(($, e, next) => next(e))
379
380  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
381    const { Box, Button, Text } = $.ui.resolve(e)
382    const s = await read($, skrzynka)
383    return (
384      <Box flexDirection="column">
385        <Text bold>{`Skrzynka — ${s.pozycje.length} do przeczytania`}</Text>
386        {s.pozycje.length === 0 && (
387          <Text dimColor>{s.odczyt === null ? 'Skan…' : 'Nic do przeczytania: raporty z 14 dni i stany innych sesji z 7 dni są przeczytane albo ich nie ma.'}</Text>
388        )}
389        {s.pozycje.map(p => (
390          <Box key={p.id} gap={1}>
391            <Button key={`wczytaj-${p.id}`} plain label="Wczytaj" onPress={() => wczytaj($, p)} />
392            <Button key={`przeczytane-${p.id}`} plain label="Przeczytane" onPress={() => oznaczToastem($, [p])} />
393            <Text wrap="truncate-end" color={p.alarm ? 'error' : undefined}>
394              {opis(p)}
395            </Text>
396          </Box>
397        ))}
398        <Box gap={1}>
399          {s.pozycje.length > 0 && <Button key="wszystkie" label="Wszystkie przeczytane" onPress={() => oznaczToastem($, s.pozycje)} />}
400          <Button key="odswiez" plain hotkey="o" label="odśwież" onPress={() => skanuj($).catch(() => undefined)} />
401          <Button key="zamknij" role="dismiss" label="Zamknij" onPress={() => $.ui.close({ id: PANE })} />
402        </Box>
403        <Text dimColor wrap="truncate-end">
404          {stopka(s)}
405        </Text>
406      </Box>
407    )
408  }).catch(($, e, next) => next(e))
409}
410
types/index.d.ts 42 lines
1// Kontrakt stanu modu kanon-skrzynka ($.state sesji; po reloadzie modułu wartości zostają, zmienne modułu startują od nowa).
2// Między sesjami żyje tylko $.store (klucz `skrzynka:<pełna ścieżka>` → `{ kiedy, sid }`): oznaczenie „przeczytane”, bez treści.
3export type Zrodlo = 'watch' | 'usage' | 'stan'
4
5/** Jedna pozycja „do przeczytania”. Nigdy nie niesie treści pliku — tylko nazwę, ścieżkę, datę i linię nagłówka. */
6export type Pozycja = {
7  zrodlo: Zrodlo
8  /** Klucz elementów panelu (litery, cyfry, `-`): `watch-2026-10-16`, `usage-2026-10-15`, `stan-9f9dcef8`. */
9  id: string
10  /** Pełna ścieżka pliku — klucz `$.store` `skrzynka:<sciezka>` i tekst „wczytaj <sciezka>” do pola promptu. */
11  sciezka: string
12  /** Nazwa pliku bez `.md` — po niej obserwator `prompt.submit` poznaje polecenie „wczytaj …”. */
13  plik: string
14  /** Krótka nazwa: `WATCH-10-16`, `USAGE-10-15`, `stan 9f9dcef8`. */
15  nazwa: string
16  /** Tekst pozycji na pasku: `WATCH-10-16 (ROZWAŻ)`, `🔴 USAGE-10-09 (2 dni 45 %)`, `stan 9f9dcef8 (16.10)`. */
17  etykieta: string
18  /** Nagłówek do panelu: linia WERDYKT bez gwiazdek, podsumowanie USAGE, linia `## Stan …` bez `## ` — nigdy treść. */
19  naglowek: string
20  /** mtime pliku z `$.fs.list` (ms). */
21  mtimeMs: number
22  /** USAGE z udziałem ostatnich 2 dni > 40 % okna 7 dni. */
23  alarm: boolean
24}
25
26export type Skrzynka = {
27  /** Nieprzeczytane, w kolejności źródeł watch → usage → stan, w źródle od najnowszego. */
28  pozycje: Pozycja[]
29  /** Czas ostatniego skanu (`$.clock.now()`); null przed pierwszym. */
30  odczyt: number | null
31  /** Błąd ostatniego skanu (brak HOME, odrzucony zapis stanu); null po udanym. Brak katalogu albo pliku to nie błąd. */
32  blad: string | null
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'kanon-skrzynka': {
38      skrzynka: Skrzynka
39    }
40  }
41}
42