
Mody w Claude Code: panel, który pokazuje, co robią subagenci
Kiedy Claude Code uruchamia kilku subagentów naraz, widzisz tylko, że coś pracuje. Nie wiesz, kto dostał jakie zadanie, ile narzędzi już użył, ile zużył tokenów ani co odpowiedział. Od wersji 2.1.287 możesz to zmienić sam: Claude Code obsługuje mody, czyli wtyczki, które działają wewnątrz programu i potrafią rysować własny interfejs.
Poniżej budujemy taki mod. Dodaje komendę /agents, która otwiera panel z drzewem agentów. Przy każdym widać zadanie, które dostał od rodzica, liczbę wywołanych narzędzi, zużyte tokeny i końcową odpowiedź.
Kod uruchomiłem w teście (claude plugin test) na Claude Code 2.1.296 i przeszedł walidację. Nie sprawdziłem go jeszcze w żywej sesji z prawdziwymi subagentami, więc nie wiem, jak panel wygląda w terminalu. Piszę o tym od razu, bo między „działa w teście” a „działa u Ciebie” jest spora różnica.
Czym jest mod
Mod to zwykła wtyczka (plugin) Claude Code z jedną różnicą: ma plik z kodem JavaScript lub TypeScript, a w nim funkcje, które Claude Code woła, gdy coś się dzieje. Takie zdarzenia to na przykład wywołanie narzędzia, wysłanie promptu, start subagenta albo rysowanie spinnera. Funkcję reagującą na zdarzenie dokumentacja nazywa hookiem.
O hookach w narzędziach AI pisałem w osobnym wpisie. Ten sam termin oznacza jednak dwie różne rzeczy. Hook z pliku ustawień Claude Code uruchamia osobny skrypt albo żądanie HTTP. Hook w modzie jest funkcją wewnątrz Claude Code, więc ma coś, czego tamte nie mają: może narysować panel, pasek nad promptem, przyciski i pola tekstowe. Skill, serwer MCP i hook z ustawień niczego nie narysują.
Hook dostaje trzy argumenty: $ (API modów), e (zdarzenie) oraz next (przekazanie zdarzenia dalej). Może zdarzenie obejrzeć, zmienić albo odpowiedzieć na nie sam. Nasz mod będzie tylko obserwował, więc hooki zbierające dane przekażą zdarzenie dalej przez next.
Mod nie wymaga Node.js ani bundlera. Claude Code ładuje pliki .js i .ts bezpośrednio.
Pełny przegląd modów (instalacja, bezpieczeństwo, zasady dla zespołów) jest w osobnym wpisie. Tutaj skupiam się na jednym przykładzie.
Jakie dane o agentach daje Claude Code
Zanim napisałem kod, sprawdziłem w deklaracjach typów, które zdarzenia niosą informacje o subagentach. Wystarczy ich, żeby zbudować panel:
| Co chcemy pokazać | Skąd to wziąć |
|---|---|
| Że subagent wystartował i jakie dostał zadanie | zdarzenie agent.spawn: pola prompt, description, subagentType, parentAgentId, a wynik zawiera agentId |
| Którego agenta dotyczy wywołanie narzędzia | pole agentId w tool.call; brak pola oznacza główną rozmowę |
| Odpowiedź i koszt agenta | zdarzenie turn.complete: pola agentId, answer i usage |
| Aktualny status i rodzic | $.agent.list(): pola id, status, parentId, type, description |
Jest tu jedno ograniczenie. Nie znalazłem osobnego zdarzenia „subagent skończył”. Wiemy, kiedy agent zakończył turę (turn.complete), ale status całego agenta (completed, failed, killed) trzeba odczytać z $.agent.list(). Dlatego mod co sekundę odpytuje tę listę.
Pliki modu
Mod to trzy pliki, tak samo jak w dokumentacji:
agent-radar/├── .claude-plugin/│ └── plugin.json└── hooks/ ├── hooks.json └── register.jsManifest plugin.json:
{ "name": "agent-radar", "version": "0.1.0", "description": "Panel /agents: drzewo subagentów, ich zadania, narzędzia, tokeny i odpowiedzi", "author": { "name": "EfektywnyDev" }}Plik hooks.json mówi Claude Code, gdzie jest kod. Sama obecność klucza modules sprawia, że wtyczka staje się modem:
{ "modules": ["./register.js"] }Kod: register.js
// Jeden wpis na agenta. Klucz 'main' to główna rozmowa.const agents = new Map([['main', { label: 'main', type: 'main', status: 'running', tools: 0, tokens: 0, task: '', answer: '' }]])const PANE = 'agent-radar'const STATUS = { running: 'pracuje', completed: 'gotowy', failed: 'błąd', killed: 'przerwany' }
const row = (id) => agents.get(id) ?? { label: id, type: '?', status: '?', tools: 0, tokens: 0, task: '', answer: '' }// Skraca tekst do jednej linii o długości nconst short = (text, n) => { const one = text.replace(/\s+/g, ' ') return one.length > n ? one.slice(0, n - 1) + '…' : one}
export function register(on) { on('session.start', async ($, e, next) => { await $.command.register({ name: 'agents', description: 'Pokaż drzewo agentów i ich zadania', immediate: true }) // Statusy subagentów nie mają osobnego zdarzenia, więc co sekundę pytamy o listę $.clock.every(1000, async () => { for (const a of await $.agent.list()) { const r = row(a.id) agents.set(a.id, { ...r, label: a.description, type: a.type, status: a.status, parent: a.parentId ?? 'main' }) } $.ui.invalidate('ui.render') }) return next(e) })
on('command.run', { command: 'agents' }, async ($) => { await $.ui.open({ id: PANE, title: 'Agenci', focus: true, closeOnEscape: true }) return {} })
// Start subagenta: zapisujemy zadanie, które dostał od rodzica on('agent.spawn', async ($, e, next) => { const result = await next(e) if (result.agentId) { agents.set(result.agentId, { ...row(result.agentId), label: e.description, type: e.subagentType, parent: e.parentAgentId ?? 'main', task: e.prompt, status: 'running', }) $.ui.invalidate('ui.render') } return result })
// Każde wywołanie narzędzia liczymy agentowi, który je zlecił on('tool.call', async ($, e, next) => { const id = e.agentId ?? 'main' agents.set(id, { ...row(id), tools: row(id).tools + 1 }) $.ui.invalidate('ui.render') return next(e) })
// Koniec tury agenta: jego odpowiedź i zużyte tokeny on('turn.complete', async ($, e, next) => { const id = e.agentId ?? 'main' const used = e.usage ? e.usage.input_tokens + e.usage.output_tokens : 0 agents.set(id, { ...row(id), tokens: row(id).tokens + used, answer: e.answer }) $.ui.invalidate('ui.render') return next(e) })
on('ui.render', { component: 'Pane' }, async ($, e, next) => { if (e.requestId !== PANE) return next(e) const { Box, Text } = $.ui.resolve(e) const width = e.props.bodyColumns const lines = [] // Drzewo: rodzic, potem jego dzieci (rekurencja po polu parent) const walk = (parentId, depth) => { for (const [id, a] of agents) { if (id === 'main' || (a.parent ?? 'main') !== parentId) continue const mark = a.status === 'completed' ? '✓' : a.status === 'running' ? '●' : '◌' const pad = ' '.repeat(depth) lines.push(Text({ children: [pad + short(mark + ' ' + a.label + ' (' + a.type + '): ' + (STATUS[a.status] ?? a.status) + ' · narzędzia: ' + a.tools + ' · tokeny: ' + a.tokens, width - pad.length)] })) if (a.task) lines.push(Text({ dimColor: true, children: [pad + short(' zadanie: ' + a.task, width - pad.length)] })) if (a.answer) lines.push(Text({ dimColor: true, children: [pad + short(' odpowiedź: ' + a.answer, width - pad.length)] })) walk(id, depth + 1) } } const main = row('main') lines.push(Text({ bold: true, children: ['● główna rozmowa · narzędzia: ' + main.tools + ' · tokeny: ' + main.tokens] })) walk('main', 1) return Box({ flexDirection: 'column', children: lines }) })}Najważniejsze decyzje w tym pliku:
- Jeden wpis na agenta w mapie
agents. Kluczmainto główna rozmowa, pozostałe klucze toagentIdzwrócone przezagent.spawn. Hooki tylko dopisują do mapy i proszą o przerysowanie panelu przez$.ui.invalidate('ui.render'). agent.spawnczeka nanext(e). Dopiero wynik zawieraagentId, więc wpis powstaje po starcie subagenta. Tekst zadania bierzemy ze.prompt.turn.completezagentIdto odpowiedź subagenta. Dokumentacja typów mówi wprost, że odpowiedź subagenta przychodzi właśnie w jegoturn.complete.- Panel to tylko rysunek aktualnego stanu. Hook
ui.renderskłada drzewo z elementówBoxiTextna podstawie mapy. Claude Code woła go ponownie po każdyminvalidate, ale ogranicza częstotliwość przerysowań, więc szybkie serie próśb zlewają się w jedno odświeżenie. - Hooki zbierające dane zawsze przekazują zdarzenie dalej.
agent.spawn,tool.calliturn.completekończą się nanext, więc mod niczego nie blokuje ani nie zmienia. Jeśli któryś z nich rzuci wyjątek, Claude Code po prostu go pominie.
Jak to uruchomić
Dla jednej sesji wystarczy flaga --plugin-dir:
claude --plugin-dir ./agent-radarPotem poproś Claude’a o zadanie, które rozdzieli na subagentów, i wpisz /agents. Panel otwiera się z fokusem klawiatury, a Esc go zamyka. Po zapisaniu zmian w register.js mod przeładowuje się bez restartu sesji (przeładowanie zeruje mapę agents).
Przed instalacją cudzego modu zawsze uruchom:
claude plugin validate ./agent-radarPolecenie nie uruchamia kodu, tylko wypisuje, jakie zdarzenia mod obsługuje i po jakie API sięga. Dla naszego modu wynik wygląda tak (pominąłem ostrzeżenia o hookach bez obsługi błędów, które dla modu tylko obserwującego nie mają znaczenia):
hooks: session.start, command.run{command=agents}, agent.spawn, tool.call, turn.complete, ui.render{component=Pane}calls: $.agent.list, $.clock.every, $.command.register, $.ui.invalidate, $.ui.open, $.ui.resolveW linii calls: nie ma $.fs, $.process ani $.http, czyli mod nie czyta plików, nie uruchamia programów i nie łączy się z siecią. To dobry sygnał, że robi tylko to, co obiecuje.
Test bez uruchamiania sesji
Mody da się testować poleceniem claude plugin test, bez logowania i bez sieci. Test udaje Claude Code: wysyła do modu zdarzenia i sprawdza, co mod narysował. Mój test przechodzi cały scenariusz: start subagenta, dwa wywołania narzędzi, zakończenie tury i otwarcie panelu.
import { expect, mock, test } from 'claude-code/testing'
const PANE = { plugin: 'agent-radar', component: 'Pane', requestId: 'agent-radar', surface: 'terminal', viewport: { columns: 100, rows: 30 }, props: { title: 'Agenci', isFocused: true, bodyColumns: 80, placement: 'inline', scroll: { offset: 0, bodyRows: 10 }, view: {} },} as const
test('panel pokazuje zadanie, narzędzia i odpowiedź subagenta', async ($, on) => { const clock = mock.clock(on) on('command.register', () => ({ value: undefined })) on('agent.spawn', () => ({ model: 'haiku', agentId: 'a1' })) on('tool.call', () => ({ result: 'ok' })) on('turn.complete', () => ({ text: '' })) on('session.start', () => ({ cwd: '/work' })) on('agent.list', () => ({ value: [{ id: 'a1', description: 'Przegląd diffa', type: 'reviewer', status: 'completed' }] }))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) await $.agent.spawn({ prompt: 'Sprawdź diff', description: 'Przegląd diffa', subagentType: 'reviewer' }) await $.tool.call({ tool: 'Grep', agentId: 'a1' }) await $.tool.call({ tool: 'Read', agentId: 'a1' }) await $.turn.complete({ turnId: 't', agentId: 'a1', answer: 'Dwa drobiazgi', durationMs: 5, isAborted: false, usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, model: 'haiku' } }) await clock.advance(1000)
const ui = await $.ui.mount(PANE) // dziecko głównej rozmowy ma wcięcie dwóch spacji expect(await ui.find({ type: 'Text', text: /^ ✓ Przegląd diffa \(reviewer\): gotowy · narzędzia: 2 · tokeny: 120/ })).toBeDefined() expect(await ui.find({ type: 'Text', text: /zadanie: Sprawdź diff/ })).toBeDefined() expect(await ui.find({ type: 'Text', text: /odpowiedź: Dwa drobiazgi/ })).toBeDefined()})Dwie uwagi z pisania testu. Po pierwsze, test od razu przeszedł, więc najpierw celowo wpisałem złą liczbę tokenów i upewniłem się, że wtedy pada. Po drugie, czytając kod po napisaniu testu, znalazłem błąd: moja funkcja skracająca tekst zamieniała ciągi spacji na jedną, więc wcięcia drzewa znikały. Test tego nie wychwycił, dopóki nie dopisałem asercji na wcięcie. Zielony test sprawdza tylko to, o co go zapytasz.
Z danych tego testu panel składa takie linie:
● główna rozmowa · narzędzia: 0 · tokeny: 0 ✓ Przegląd diffa (reviewer): gotowy · narzędzia: 2 · tokeny: 120 zadanie: Sprawdź diff odpowiedź: Dwa drobiazgiTo wynik z danych testowych, a nie zrzut z prawdziwej sesji.
Czego ten panel nie pokazuje
Łatwo uznać, że panel pokaże rozmowy agentów, ale subagenci rozmawiają mniej, niż się wydaje. Rodzic daje subagentowi zadanie przy starcie i dostaje jedną odpowiedź na końcu. Panel pokazuje dokładnie te dwa punkty: zadanie i odpowiedź. Między nimi subagent pracuje sam.
Prawdziwe wiadomości krążą między sesjami, subagentami i członkami zespołu agentów (teammates), na przykład przez narzędzie SendMessage. Mod może je obserwować przez zdarzenia session.send i session.receive, które niosą adresata, treść i rodzaj nadawcy. Nie dodałem tego do panelu, bo żeby sprawdzić, jak te zdarzenia wyglądają w praktyce, potrzebuję żywej sesji z zespołem agentów. To naturalny następny krok.
Jest też ograniczenie samego rysowania: panel pojawia się tylko w terminalu i w aplikacji Desktop. W rozszerzeniu VS Code hooki działają, ale nic się nie rysuje.
Bezpieczeństwo: mod to kod z Twoimi uprawnieniami
Tego akapitu nie pomijaj. Mod nie działa w piaskownicy. Może czytać Twoje pliki i zmienne środowiskowe (w tym klucze API), widzi każdy prompt i każde wywołanie narzędzia, a nawet może zatwierdzić wywołanie, zanim Claude Code zapyta Ciebie. Instaluj mody tylko od autorów i z marketplace’ów, którym ufasz, a przed instalacją uruchom claude plugin validate.
Mody wyłączysz pojedynczo w /plugin, wszystkie naraz na jedną sesję flagą --safe-mode, a na stałe ustawieniem "disableAllHooks": true. Administratorzy zespołów mają dodatkowo allowManagedModsOnly, które blokuje mody instalowane przez użytkowników.
Co dalej
Mod z tego wpisu to punkt wyjścia. Warto go rozbudować w trzech kierunkach: dopisać wiadomości z session.send i session.receive, przenieść mapę do $.state, żeby przeżywała przeładowanie modu, oraz pokazać koszt w pieniądzach zamiast surowych tokenów. Anthropic udostępnia też trzy gotowe przykłady w repozytorium claude-code-playground: token-weather (prognoza zapełnienia kontekstu), blast-radius (wstrzymuje ryzykowne komendy) i replay-theater (krok po kroku odtwarza edycje z ostatniej tury).
Źródła
- Mods overview, Create a mod, React to events, Draw in the interface, Use the mods API, Test a mod i Manage mods for your organization w dokumentacji Claude Code (stan na 13 października 2026).
- Deklaracje typów modów: pola zdarzeń
agent.spawn,tool.calliturn.completeoraz$.agent.list().
Nowe materiały prosto na maila
Publikuję nowy artykuł albo lekcję kursu — dostajesz jednego maila. Nic poza tym.
Bez cyklu wydawniczego i newsów o każdej premierze modelu
Zapisy do newslettera otwieram partiami, żeby nie zamieniać go w kolejny kanał ze spamem i recyklingiem linków.
Jeśli chcesz dostać informację przy następnym otwarciu zapisów, napisz na [email protected].

O autorze
Marcin Szymański
Od kilkunastu lat buduję systemy w .NET i Azure — obecnie rozproszony monitoring PingView. Piszę tu o pracy człowiek + agenci AI na realnych workflow, nie na demach. Zawodowo prowadzę audyty, rescue projektów i wdrożenia AI-assisted delivery w zespołach.