SLOPSHOPPER

kanon-na-biegu

Sekcja „⚡ Na biegu” w pasku bocznym desktopu: sesja, w której zaczyna się tura (nowa albo wznowiona), sama się do niej wkłada; po 24 h bezczynności wraca do…

guardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kanon-na-biegu
› fix the failing auth test and add an audit log call ● kanon-na-biegu: Na biegu: undefined is not an object (evaluating 'r.content.map') ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /na-biegu ⎿ kanon-na-biegu: Na biegu: błąd — undefined is not an object (evaluating 'r.content.map') · ostatni błąd w tle: undefined is no ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

kanon-na-biegu — sekcja „⚡ Na biegu” w pasku bocznym desktopu

Mod Claude Code (API „function hooks”, early access; pisany pod 2.1.299, testy zielone na desktop 2.1.299 i CLI 2.1.289). Z prośby właściciela (2026-10-16): świeże sesje (ostatnia doba) i nieprzeczytane mają być widać po lewej, zamiast ginąć w głębi listy.

Pasek boczny należy do aplikacji desktop — mod go nie rysuje, tylko przekłada sesje narzędziami MCP ccd_sidebar / ccd_session_mgmt (tymi samymi, które ma model). Sekcję tworzy i ustawia właściciel; mod szuka jej po nazwie.

  • Włączenie: w pasku bocznym grupa o nazwie dokładnie ⚡ Na biegu (przeciągnięta tam, gdzie ma być). Bez niej mod nic nie robi — usunięcie sekcji to wyłącznik.
  • Wkładanie: start tury w głównej rozmowie (nowa sesja albo wznowiona stara; tura subagenta się nie liczy) → sesja przenosi samą siebie (self) do sekcji i zapisuje w $.store, z której grupy przyszła (null = Ungrouped). Nie rusza: przypiętej (przeniesienie do grupy zdjęłoby pin), już będącej w sekcji, ani takiej, która ma wpis z bieżącego okresu aktywności — czyli sesji, którą właściciel ręcznie wyjął z sekcji, mod ponownie nie wkłada, dopóki pracuje. Każda tura odświeża czas wpisu; okres kończy doba bez tury — wtedy wznowiona sesja wraca do sekcji (z jej obecną grupą jako grupą pochodzenia).
  • Grupy nieznane: gdy okno aplikacji nie zgłosiło jeszcze grup (get_session bez klucza group — opis narzędzia: „unknown, not ungrouped”), mod w tej turze nic nie przekłada ani nie zapisuje.
  • Sprzątanie: przy starcie tury, najwyżej co 15 min na sesję (pierwsza tura po starcie lub przeładowaniu modu — od razu): sesje z sekcji bezczynne > 24 h (lastActivityAt) wracają do grupy pochodzenia; bez wpisu (np. wrzucone do sekcji ręcznie) albo gdy tamtej grupy już nie ma — do Ungrouped. Przypięte i bieżąca sesja zostają. Wpisy sesji spoza sekcji (wyjęte, zarchiwizowane, usunięte) po dobie bezczynności idą do kosza. Wpisy sesji, które wróciły, znikają zaraz po ich ruchu; nieudany ruch zostawia wpis (z grupą pochodzenia) do następnej próby. Sprzątanie czyta najwyżej 100 sesji z sekcji — przy pełnej liście wpisów spoza niej nie rusza.
  • /na-biegu: stan tej sesji (w sekcji / poza / przypięta), sprzątanie teraz z liczbą odłożonych, ostatnia akcja i ostatni błąd w tle.
  • Czas tury: praca modu przy starcie tury czeka najwyżej 3 s (pasek to lokalne IPC aplikacji, milisekundy); dłuższa kończy się w tle. Błąd w tle → jedna szara linia Na biegu: … w transkrypcie (powtórki tylko w /na-biegu).

Czego mod nie umie

  • „Nieprzeczytane”: narzędzia ccd_sidebar niebieską kropkę tylko ustawiają (set_unread), nie podają. Zastępczo sesja zostaje w sekcji dobę od ostatniej aktywności, więc nieprzeczytany wynik z ostatniej doby w niej jest.
  • Sesje bez modu same się nie wkładają: mod działa w sesji, która go ładuje (po wpisie do CLAUDE_CODE_PLUGIN_DIRS — każda nowa). Sprzątanie obejmuje całą sekcję, także sesje bez modu.
  • Sprzątanie bez tur: gdy żadna sesja z modem nie zaczyna tury, nic się nie przekłada — przeterminowane czekają do najbliższej tury.
  • Terminal / bez aplikacji desktop: narzędzi ccd_* nie ma → jedna linia błędu, tura idzie dalej.
  • Grupa pochodzenia sesji wrzuconej do sekcji ręcznie jest nieznana — wraca do Ungrouped (na żywo 2026-10-16: „Notyfikacje monitorowania aktywności sklepu” — tak było).

Zgoda na przeniesienia (decyzja właściciela 2026-10-16, wariant A)

W trybie auto silnik daje move_sessions wywołane przez mod klasyfikatorowi, który wywołań spoza transkryptu nie ocenia („server-side auto mode classifier gave no verdict” → odmowa). Mod ma więc hook tool.check na mcp__ccd_sidebar__move_sessions: przepuszcza („ask” → „allow”) WYŁĄCZNIE wywołania, które silnik przypisał temu modowi (next.origin.plugin === 'kanon-na-biegu' — pole ustawia silnik, nie wołający). Bez zmian zostają: werdykt „deny” z reguł właściciela, sufit organizacji (e.ceiling), wywołania modelu i innych pluginów (test: dostają werdykt silnika). Hook, który się wysypie, silnik pomija (docs reference.md: „gating hook … skipped”) — zostaje werdykt silnika, czyli odmowa, nie zgoda. Walidator wypisuje go jako gating hook without .catch — to fakt, nie ostrzeżenie.

Sprawdzone na żywo (desktop 2.1.299, tryb auto, 2026-10-16)

  1. list_sessions bez filtra (setki sesji, ~90 tys. znaków) silnik podmienia na tekst „Error: result … exceeds maximum allowed tokens” z isError: false → mod czyta tylko sekcję (group), a wolaj traktuje prefiks Error jako błąd.
  2. Ruch bez hooka tool.check → odmowa klasyfikatora; z hookiem, ze startu tury → origin {plugin: kanon-na-biegu}, werdykt silnika ask → allow, ruch wykonany.
  3. Wywołanie z callbacku $.clock (timer) omija hook tool.check modu i dostaje odmowę („the request that produced this action did not ask for one”) → mod nie ma timerów; sprzątanie przy starcie tury.
  4. Po przebudowie: przy starcie tury „Flota” i „Notyfikacje…” (bezczynne od 2026-10-11) wróciły do Ungrouped; bieżąca sesja i dwie aktywne zostały w sekcji.
  5. $.ui.log w sesji desktop nie trafia do pliku transkryptu (diagnostyka na żywo szła sondą $.fs.write w kopii dev, nie w repo).

Bramki

Zero $.model, $.http, $.fs, $.process; tool.check tylko na move_sessions i tylko dla własnych wywołań. W $.store wyłącznie na-biegu:<id sesji> → { origin: <id grupy> | null, at: <ms ostatniej tury> } — bez tytułów, cwd i treści rozmów (test). Mod nie tworzy, nie usuwa ani nie zmienia nazw sekcji; nie archiwizuje sesji, nie zmienia pinów ani kropek.

Linie walidatora (claude plugin validate, 2.1.299):

hooks: session.start, turn.start, tool.check{tool=mcp__ccd_sidebar__move_sessions}, command.run{command=na-biegu}
answers its own command: command.run{command=na-biegu}
gating hook without .catch: tool.check{tool=mcp__ccd_sidebar__move_sessions}
calls: $.clock.now (via naTurze, sprzataj, wloz, zanotuj), $.clock.sleep, $.command.register, $.mcp.call (via wolaj), $.store.delete (via sprzataj), $.store.get (via sprzataj, wloz), $.store.keys (via sprzataj), $.store.set (via wloz), $.ui.log (via zapiszBlad)

Testy

claude plugin test . — 18 testów, zielone na desktop 2.1.299 i CLI 2.1.289 (pasek i serwery ccd_* zamockowane w teście, łącznie z limitem wyniku list_sessions): nowa i wznowiona sesja, przypięta, wyjęta ręcznie (pracuje 40 h — nie wraca; po dobie bez tury — wraca; wraca też bez sprzątania w międzyczasie), grupy niezgłoszone, tura subagenta, brak sekcji, brak desktopu, odmowa ruchu, sprzątanie (grupa pochodzenia / Ungrouped / grupa usunięta / przypięta / świeża / wpisy do kosza), odmowa przy drugim celu, lista ucięta limitem, nieudany powrót, store bez treści, werdykt silnika dla wywołań spoza modu, zgoda() we wszystkich gałęziach (origin modu / inny / brak, „ask” / „deny”, sufit), sprzątanie co 15 min przy turze bez ruszania bieżącej sesji. Mutacje na kopii (każda łapana przez ≥ 1 test): bez filtra group, bez pomijania bieżącej sesji, bez dławika 15 min, sprzątanie w turze subagenta, wpis bez sprawdzania wieku, bez odświeżania wpisu, kasowanie wpisów dopiero po wszystkich ruchach, nieznane grupy jak Ungrouped, bez strażnika limitu, zgoda() bez origin / bez sufitu / zamieniająca „deny”, hook ze stałym originem. Bez pokrycia w kicie: czy hook woła zgoda() dla własnych wywołań — własne $.mcp.call modu w kicie nie przechodzi przez tool.check, a $ testu ma origin silnika; ta gałąź sprawdzona na żywo (pkt 2 i 4 wyżej). Type-check: tsc 7.0.2 strict (tsconfig modu rozszerza ten, który silnik kładzie w .claude-plugin/types/).

Recenzja (sceptyk [OP5.5HI], 2 rundy, 2026-10-16)

Runda 1 — naprawione: 🔴 sesja wyjęta ręcznie i wznowiona po > dobie nie wracała do sekcji (wpis bez sprawdzania wieku, a sprzątanie pomija bieżącą sesję) → wiek wpisu + odświeżanie co turę; 🟡 odmowa przy drugim celu sprzątania zostawiała wpisy sesji, które już wróciły → kasowanie zaraz po ruchu; 🟡 brak klucza group brany za Ungrouped (gubił grupę pochodzenia) → mod czeka; 🟡 gałęzie allow / deny / sufit bez testu → zgoda() z testem wszystkich gałęzi; 🟢 lista ucięta limitem kasowała wpisy → strażnik limitu. Runda 2: wszystkie zamknięte (scenariusze rundy 1 na nowym kodzie, 10 mutantów łapanych; hook_passthru przeżywa — luka kitu jak wyżej).

Znane ograniczenie (🟢 z rundy 2, wyścig, symulowany kitem): gdy sprzątanie innej sesji przenosi sesję X z sekcji w tej samej milisekundowej chwili, w której X odświeża swój wpis przy starcie tury, wpis X wraca — X zostaje poza sekcją do doby bez tury (przeciągnięcie ręczne naprawia od razu). Okno: odczyt → zapis wpisu w X (ms) × sprzątanie innej sesji najwyżej co 15 min.

Uruchomienie i rollback

Próba w jednej sesji: kopia w ~/.claude/dev-mods/<session_id>/ (desktop, hot reload) albo claude --plugin-dir …/kanon-na-biegu. Na stałe: ~/Developer/claude-mods-stabilne/kanon-na-biegu w env.CLAUDE_CODE_PLUGIN_DIRS (z backupem), od NOWEJ sesji. Rollback: usunięcie ścieżki z env (od nowej sesji) albo od razu — usunięcie sekcji ⚡ Na biegu (aplikacja przenosi jej sesje do Ungrouped i nic nie kasuje — opis delete_section); dane modu: rm -f ~/.claude/plugins/store/kanon-na-biegu*.

Source 1 files
hooks/register.ts 199 lines
1// kanon-na-biegu — sekcja „⚡ Na biegu” w pasku bocznym desktopu (mod Claude Code, API function hooks 2.1.299; early access). v0.1.
2// Pasek boczny należy do aplikacji desktop — mod go nie rysuje, tylko przekłada sesje narzędziami MCP `ccd_sidebar` (te same, co ma model).
3// Tura w głównej rozmowie (nowa sesja albo wznowiona stara) → sesja sama wkłada się do sekcji („self” — bez pytania) i zapisuje w `$.store`,
4// skąd przyszła. Przy starcie tury (najwyżej co 15 min na sesję) mod odkłada sesje bezczynne > 24 h do sekcji, z której przyszły (Ungrouped,
5// gdy tej już nie ma). Bez timerów: ruch z timera silnik daje klasyfikatorowi trybu auto z pominięciem hooka `tool.check` modu → odmowa
6// (sprawdzone na żywo 2026-10-16); ze startu tury i z `/na-biegu` hook działa.
7// Czego NIE wiadomo: niebieskiej kropki „nieprzeczytane” — narzędzia ccd ją tylko ustawiają, nie podają. Zastępczo sesja zostaje w sekcji
8// 24 h od ostatniej aktywności, więc nieprzeczytany wynik z ostatniej doby w niej jest.
9// Uszanowanie ręcznych ruchów: przypiętej sesji mod nie rusza (przeniesienie do grupy zdjęłoby pin); sesji, którą właściciel wyjął z sekcji,
10// nie wkłada ponownie, dopóki nie minie doba bez tury (wpis w store = „już obsłużona w tym okresie aktywności”; każda tura go odświeża).
11// Grupy nieznane (okno aplikacji ich jeszcze nie zgłosiło: brak klucza `group` w `get_session`) → mod w tej turze nic nie robi.
12// Wyłącznik: usunąć sekcję — mod szuka jej po nazwie, sam jej nie tworzy i bez niej nic nie robi.
13// Zgoda: w trybie auto silnik daje `move_sessions` modu klasyfikatorowi, który wywołań spoza transkryptu nie ocenia („gave no verdict”
14// → odmowa). Mod przepuszcza więc przy `tool.check` WYŁĄCZNIE własne przeniesienia (`next.origin.plugin` = ten mod, ustawia go silnik):
15// „ask” → „allow”; „deny” z reguł właściciela i sufit organizacji zostają. Wywołania modelu i innych pluginów idą jak dotąd
16// (decyzja właściciela 2026-10-16, wariant A, zatwierdzony w oknie zgody).
17// Bramki: zero modelu, zero sieci; w store tylko id sesji, id grupy i czas — żadnej treści rozmów.
18import type { EngineInterface, Register, ToolCheckDecision, ToolCheckResult } from 'claude-code'
19
20const SEKCJA = '⚡ Na biegu'
21const SERWER = 'ccd_sidebar'
22const SESJE = 'ccd_session_mgmt'
23const KLUCZ = 'na-biegu:'
24const NAZWA = 'kanon-na-biegu' // = "name" z plugin.json; tak `next.origin.plugin` nazywa wywołania tego modu
25const PRZENIES = 'mcp__ccd_sidebar__move_sessions'
26const DOBA = 86_400_000
27const SPRZATANIE = 15 * 60_000
28const LIMIT = 100 // sesji z sekcji na jedno sprzątanie
29
30type Sekcja = { id: string; kind: string; name: string }
31type Sesja = { sessionId: string; lastActivityAt?: string; group?: { id: string } | null; pinned?: boolean }
32/** Wpis sesji obsłużonej w bieżącym okresie aktywności: grupa, z której przyszła (null = Ungrouped), i ostatnia tura (ms). */
33type Wpis = { origin: string | null; at: number }
34
35const skroc = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
36const blad = (err: unknown) => skroc(err instanceof Error ? err.message : String(err), 160)
37
38/** Wywołanie narzędzia aplikacji desktop; błąd → wyjątek z tekstem serwera. Zwraca tekst wyniku.
39 *  Silnik podmienia wynik ponad swój limit tokenów na tekst „Error: result … exceeds maximum allowed tokens” BEZ `isError` — stąd test prefiksu. */
40async function wolaj($: EngineInterface, serwer: string, narzedzie: string, args: Record<string, unknown>): Promise<string> {
41  const r = await $.mcp.call(serwer, narzedzie, args)
42  const tekst = r.content.map(c => c.text ?? '').join('')
43  if (r.isError || /^Error\b/.test(tekst)) throw new Error(`${narzedzie}: ${skroc(tekst, 120)}`)
44  return tekst
45}
46
47/** Sekcje paska i id „⚡ Na biegu” (null, gdy właściciel ją usunął). */
48async function sekcje($: EngineInterface): Promise<{ nasza: string | null; wszystkie: Set<string> }> {
49  const l = JSON.parse(await wolaj($, SERWER, 'list_sections', {})) as Sekcja[]
50  return { nasza: l.find(s => s.kind === 'manual' && s.name === SEKCJA)?.id ?? null, wszystkie: new Set(l.map(s => s.id)) }
51}
52
53const ja = async ($: EngineInterface) => JSON.parse(await wolaj($, SESJE, 'get_session', { session_id: 'self' })) as Sesja
54
55// Diagnostyka dla `/na-biegu`: pamięć modułu (po reloadzie od zera).
56let ostatni: { co: string; at: number } | null = null
57let bladOstatni: string | null = null
58let sprzatanieOstatnie = 0 // po reloadzie pierwsza tura sprząta od razu
59const zanotuj = async ($: EngineInterface, co: string) => {
60  ostatni = { co, at: await $.clock.now() }
61}
62/** Błąd pracy w tle: nowy (inny niż poprzedni) — jedna szara linia w transkrypcie; powtórki co 15 min widać tylko w `/na-biegu`. */
63const zapiszBlad = ($: EngineInterface) => (e: unknown) => {
64  const b = blad(e)
65  if (b !== bladOstatni) $.ui.log(`Na biegu: ${b}`)
66  bladOstatni = b
67}
68
69/** Tura w głównej rozmowie: włóż tę sesję do sekcji, chyba że jest przypięta, już w niej jest albo była obsłużona w tym okresie aktywności.
70 *  Okres kończy doba bez tury: wpis odświeża każda tura, a starszy niż doba nie blokuje — sesja wyjęta ręcznie, wznowiona po przerwie,
71 *  wraca do sekcji (sprzątanie wpisu bieżącej sesji nie rusza, więc bez tego czekałaby na sprzątanie innej sesji). */
72async function wloz($: EngineInterface, nasza: string, s: Sesja): Promise<void> {
73  if (s.pinned) return
74  const klucz = KLUCZ + s.sessionId
75  const [stary, teraz] = await Promise.all([$.store.get(klucz) as Promise<Wpis | undefined>, $.clock.now()])
76  if (s.group?.id === nasza || (stary && teraz - stary.at <= DOBA)) {
77    if (stary) await $.store.set(klucz, { ...stary, at: teraz })
78    return
79  }
80  // Najpierw ruch: po nieudanym następna tura spróbuje znowu (wpis przed ruchem zablokowałby to na dobę).
81  await wolaj($, SERWER, 'move_sessions', { session_ids: ['self'], section_id: nasza })
82  await $.store.set(klucz, { origin: s.group?.id ?? null, at: teraz } satisfies Wpis)
83  await zanotuj($, 'ta sesja → sekcja')
84}
85
86/** Sesje bezczynne > 24 h: z sekcji z powrotem tam, skąd przyszły; wpisy zakończonych okresów aktywności — do kosza. Zwraca liczbę odłożonych. */
87async function sprzataj($: EngineInterface, nasza: string, wszystkie: Set<string>, s: Sesja): Promise<number> {
88  // Tylko sesje z sekcji (filtr `group`): pełna lista (setki sesji, ~90 tys. znaków) przekracza limit wyniku MCP silnika.
89  const [lista, teraz, klucze] = await Promise.all([
90    wolaj($, SESJE, 'list_sessions', { group: nasza, limit: LIMIT }),
91    $.clock.now(),
92    $.store.keys(),
93  ])
94  // Bieżącej sesji sprzątanie nie rusza: ona właśnie pracuje (jej `lastActivityAt` bywa jeszcze sprzed tej tury). list_sessions jej nie podaje.
95  const sesje = JSON.parse(lista) as Sesja[]
96  const wpisy = new Map<string, Wpis>()
97  for (const k of klucze) {
98    if (!k.startsWith(KLUCZ)) continue
99    const w = (await $.store.get(k)) as Wpis | undefined
100    if (w) wpisy.set(k.slice(KLUCZ.length), w)
101  }
102
103  // Brak albo zły czas aktywności (NaN) → nie ruszamy.
104  const bezczynna = (x: Sesja) => teraz - Date.parse(x.lastActivityAt ?? '') > DOBA
105
106  const cele = new Map<string, string[]>() // sekcja docelowa → id sesji
107  const doKosza: string[] = []
108  for (const x of sesje) {
109    // Przypięta w sekcji: przeniesienie do grupy zdjęłoby pin — czeka, aż właściciel ją odepnie (wpis zostaje).
110    if (!bezczynna(x) || x.pinned) continue
111    const origin = wpisy.get(x.sessionId)?.origin
112    const cel = origin && wszystkie.has(origin) ? origin : 'sessions'
113    cele.set(cel, [...(cele.get(cel) ?? []), x.sessionId])
114  }
115  // Wpisy sesji spoza sekcji (wyjęte ręcznie, zarchiwizowane, usunięte) — po dobie bezczynności do kosza. Po jednej: takich jest kilka.
116  // Lista ucięta limitem nie mówi, kto jest poza sekcją — wtedy wpisów nie ruszamy (zgubiłyby grupę pochodzenia).
117  const wSekcji = new Set(sesje.map(x => x.sessionId))
118  for (const [sid, w] of sesje.length < LIMIT ? wpisy : []) {
119    if (wSekcji.has(sid) || sid === s.sessionId) continue
120    const x = await wolaj($, SESJE, 'get_session', { session_id: sid }).then(
121      t => JSON.parse(t) as Sesja,
122      (err: unknown) => (/not found/i.test(blad(err)) ? null : undefined),
123    )
124    if (x === undefined) continue // błąd chwilowy — wpis czeka do następnego sprzątania
125    if (x ? bezczynna(x) : teraz - w.at > DOBA) doKosza.push(sid)
126  }
127
128  for (const sid of doKosza) await $.store.delete(KLUCZ + sid)
129  let odlozone = 0
130  for (const [cel, ids] of cele) {
131    // Najpierw ruch, zaraz po nim wpisy tych sesji: po nieudanym ruchu wpis (i grupa pochodzenia) zostaje do następnej próby,
132    // a odmowa przy kolejnym celu nie zostawia wpisów sesji, które już wróciły.
133    await wolaj($, SERWER, 'move_sessions', { session_ids: ids, section_id: cel })
134    for (const sid of ids) await $.store.delete(KLUCZ + sid)
135    odlozone += ids.length
136  }
137  if (odlozone) await zanotuj($, `odłożone po dobie: ${odlozone}`)
138  return odlozone
139}
140
141/** Start tury w głównej rozmowie: ta sesja do sekcji, a najwyżej co 15 min sprzątanie. Czas zapisany przed sprzątaniem: nieudane
142 *  nie powtarza się co turę (każda odmowa to osobne wywołanie), tylko po kwadransie. */
143async function naTurze($: EngineInterface): Promise<void> {
144  const [{ nasza, wszystkie }, s] = await Promise.all([sekcje($), ja($)])
145  if (!nasza || s.group === undefined) return
146  await wloz($, nasza, s)
147  const teraz = await $.clock.now()
148  if (teraz - sprzatanieOstatnie < SPRZATANIE) return
149  sprzatanieOstatnie = teraz
150  await sprzataj($, nasza, wszystkie, s)
151}
152
153/** Werdykt `tool.check` dla `move_sessions`: „allow” tylko dla wywołań, które silnik przypisał temu modowi, i tylko w miejsce „ask”;
154 *  „deny” (reguły właściciela) i sufit organizacji zostają, a wywołania modelu i innych pluginów dostają werdykt silnika bez zmian. */
155export function zgoda(werdykt: ToolCheckResult, plugin: string | undefined, sufit: ToolCheckDecision | undefined): ToolCheckResult {
156  if (plugin !== NAZWA || werdykt.decision !== 'ask' || (sufit && sufit !== 'allow')) return werdykt
157  return { decision: 'allow', reason: `${NAZWA}: własne przeniesienie sesji w pasku bocznym` }
158}
159
160const godzina = (ms: number) => new Date(ms).toTimeString().slice(0, 8)
161
162export const register: Register = on => {
163  on('session.start', async ($, e, next) => {
164    await $.command.register({ name: 'na-biegu', description: 'Sekcja „⚡ Na biegu” w pasku bocznym: stan i sprzątanie teraz', immediate: true })
165    return next(e)
166  })
167
168  on('turn.start', async ($, e, next) => {
169    const r = await next(e)
170    // Subagent (agentId) to nie aktywność sesji w pasku. Czekamy najwyżej 3 s: pasek to lokalne IPC aplikacji (milisekundy), a zawieszony
171    // serwer nie może trzymać startu tury (wywołanie `$` w locie nie liczy się do budżetu hooka) — dłuższe kończy się w tle.
172    if (!e.agentId) {
173      const stop = new AbortController()
174      await Promise.race([naTurze($).catch(zapiszBlad($)), $.clock.sleep(3_000, { signal: stop.signal }).catch(() => {})])
175      stop.abort()
176    }
177    return r
178  })
179
180  // Tylko własne przeniesienia modu; błąd tego hooka = werdykt silnika (ask → klasyfikator), czyli bezpieczny kierunek.
181  on('tool.check', { tool: PRZENIES }, async (_$, e, next) => zgoda(await next(e), next.origin.plugin, e.ceiling))
182
183  on('command.run', { command: 'na-biegu' }, async $ => {
184    let wynik: string
185    try {
186      const [{ nasza, wszystkie }, s] = await Promise.all([sekcje($), ja($)])
187      if (!nasza) return { text: `Na biegu: brak sekcji „${SEKCJA}” w pasku — mod nic nie robi (utwórz ją, żeby włączyć).` }
188      if (s.group === undefined) return { text: 'Na biegu: okno aplikacji nie zgłosiło jeszcze grup paska — spróbuj za chwilę.' }
189      const odlozone = await sprzataj($, nasza, wszystkie, s)
190      const tu = s.pinned ? 'przypięta (mod jej nie rusza)' : s.group?.id === nasza ? 'w sekcji' : 'poza sekcją'
191      wynik = `Na biegu: ta sesja ${tu} · sprzątanie teraz: odłożone ${odlozone}`
192    } catch (err) {
193      wynik = `Na biegu: błąd — ${blad(err)}`
194    }
195    const ost = ostatni ? ` · ostatnio ${godzina(ostatni.at)}: ${ostatni.co}` : ''
196    return { text: `${wynik}${ost}${bladOstatni ? ` · ostatni błąd w tle: ${bladOstatni}` : ''}` }
197  })
198}
199