Панель живых сессий Claude Code и их статусов, по образцу Claude Control

Мод для Claude Code: панель со всеми живыми сессиями на этой машине, их статусами и лимитами подписки. Работает в терминале и во вкладке Code приложения Claude.
In English: a Claude Code mod that shows every live session on this machine grouped by status (waiting for a decision, working, idle), the 5-hour and weekly rate limits, and plays a sound when a session needs you or finishes. Tested on macOS; Windows support is experimental. UI is in Russian.
Лимиты · обновлено только что
Сессия ████░░░░░░ 41%
сброс через 2 ч 10 мин
Неделя ██░░░░░░░░ 18%
сброс через 3 дн 4 ч
Ждут решения: 1
● shop-api-9f · 2 мин · vscode
Работают: 1
● billing-service-64 · 5 мин · desktop
Свободны: 3
○ Черновик статьи (эта) · notes · сейчас · desktop
и ещё 9 без дела дольше 12 ч
/sessions открывает панель, если её закрыли.| macOS | Windows | Linux | |
|---|---|---|---|
| Статус проверки | проверено | экспериментально, не проверено | не проверено |
| Сессии «Работают» и «Свободны» | да | должно работать | должно работать |
| Лимиты | да | должно работать | должно работать |
| «Ждут решения» | с Claude Control | нет | нет |
| Звуки | да | нет | нет |
afplay), в терминале Windows и Linux проигрывателя нет.В терминальной сессии Claude Code:
/plugin install sessions-panel --marketplace SmartAnalyticsAV/claude-sessions-panel
Ответить y на вопрос о маркетплейсе и выбрать уровень пользователя. Во вкладке Code приложения эта команда недоступна, но мод, установленный из терминала, работает и там.
Удалить:
claude plugin uninstall sessions-panel@sessions-panel
claude agents --json (на Windows - через cmd.exe), статус «работает» или «свободна» - из реестра Claude Code ~/.claude/sessions/<pid>.json.PermissionRequest, и журнал сессии после него не дописывался. Это эвристика, возможны редкие ложные срабатывания.~/Library/Caches/sessions-panel, Windows - %LOCALAPPDATA%\sessions-panel, Linux - ~/.cache/sessions-panel (или $XDG_CACHE_HOME).Мод читает только локальные файлы Claude Code и Claude Control, запускает claude agents --json и ничего не отправляет в сеть. Как и любой мод, он работает без песочницы - прочитайте код перед установкой, его немного.
claude plugin validate .
claude plugin test .
API модов в раннем доступе и может меняться между версиями Claude Code.
MIT. Звуки синтезированы для этого проекта и распространяются под той же лицензией.
hooks/register.tsx 267 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SessionsPanelLimits, SessionsPanelRow, SessionsPanelState } from '../types'
5import { agentsArgv, cacheDirOf, changesOf, detailOf, formatAge, groupRows, limitRows, rowOf, stateOf } from './sessions'
6import type { Agent, ControlEvent, Platform, Registry } from './sessions'
7
8const PANE = 'sessions-panel'
9const TITLE = 'Сессии'
10const TICK_MS = 3000
11// `claude agents --json` starts the CLI: once per LIST_EVERY ticks, or sooner
12// when a registry file comes or goes.
13const LIST_EVERY = 5
14// Synthesized bells shipped with the mod: a clip must be the plugin's own file.
15const SOUND_WAITING = 'sounds/waiting.wav'
16const SOUND_FINISHED = 'sounds/finished.wav'
17// Shared by every session the mod runs in, in the platform's cache folder: one
18// of them plays the sounds, and `claude agents --json` is fetched once for all.
19const CACHE_NAME = 'sessions-panel'
20const ALIVE_MS = 10_000
21const CACHE_MS = 12_000
22
23type AgentsCache = { at: number; files: string; agents: Agent[] }
24
25const rows = atom({ plugin: 'sessions-panel', key: 'rows' } as const, [])
26const error = atom({ plugin: 'sessions-panel', key: 'error' } as const, null)
27const limits = atom({ plugin: 'sessions-panel', key: 'limits' } as const, null)
28
29async function readJson<T>($: EngineInterface, path: string): Promise<T | undefined> {
30 try {
31 return JSON.parse(await $.fs.read(path)) as T
32 } catch {
33 return undefined
34 }
35}
36
37async function collect(
38 $: EngineInterface,
39 home: string,
40 selfId: string,
41 agents: readonly Agent[],
42): Promise<SessionsPanelRow[]> {
43 return Promise.all(
44 agents.map(async agent => {
45 const reg = await readJson<Registry>($, `${home}/.claude/sessions/${agent.pid}.json`)
46 const ev = await readJson<ControlEvent>($, `${home}/.claude-control/events/${agent.pid}.json`)
47 let mtime: number | undefined
48 if (ev?.event === 'PermissionRequest' && ev.transcript_path) {
49 mtime = await $.fs.stat(ev.transcript_path).then(
50 stat => stat.mtimeMs,
51 () => undefined,
52 )
53 }
54
55 return rowOf(agent, reg, stateOf(agent, reg, ev, mtime), selfId)
56 }),
57 )
58}
59
60type Where = { platform: Platform; home: string; dir: string }
61
62// HOME on macOS and Linux, USERPROFILE on Windows, where OS is Windows_NT.
63async function whereOf($: EngineInterface): Promise<Where> {
64 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
65 const platform: Platform =
66 (await $.env.get('OS')) === 'Windows_NT'
67 ? 'windows'
68 : (await $.fs.exists('/System/Library/CoreServices'))
69 ? 'mac'
70 : 'linux'
71 const dir = cacheDirOf(
72 platform,
73 home,
74 CACHE_NAME,
75 await $.env.get('LOCALAPPDATA'),
76 await $.env.get('XDG_CACHE_HOME'),
77 )
78
79 return { platform, home, dir }
80}
81
82// Limits are the account's, reported with each response of any session: the
83// newest reading is kept in the shared folder for every session's panel.
84async function saveLimits($: EngineInterface, path: string, windows: SessionsPanelLimits['windows']) {
85 if (windows.length === 0) return
86 const at = await $.clock.now()
87 await $.fs.write(path, JSON.stringify({ at, windows: windows.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })) }))
88}
89
90export const register: Register = on => {
91 on('session.measure', async ($, e, next) => {
92 if (e.changed.includes('rateLimits')) {
93 const { dir } = await whereOf($)
94 await saveLimits($, `${dir}/limits.json`, e.rateLimits)
95 }
96
97 return next(e)
98 })
99
100 on('session.start', async ($, e, next) => {
101 await $.command.register({
102 name: 'sessions',
103 description: 'Открыть панель сессий Claude Code',
104 })
105
106 const { platform, home, dir } = await whereOf($)
107 const selfId = await $.session.id()
108 let agents: Agent[] = []
109 let files = ''
110 let tick = 0
111 let isRunning = false
112 let prev: Map<string, SessionsPanelState> | undefined
113 if (!(await $.fs.exists(`${dir}/limits.json`))) {
114 await saveLimits($, `${dir}/limits.json`, (await $.session.usage()).rateLimits)
115 }
116
117 // Each session checks in on every tick; the one with the smallest id among
118 // those seen lately plays the sounds, so an event chimes once, not per session.
119 const isLeader = async (now: number) => {
120 await $.fs.write(`${dir}/alive/${selfId}`, String(now))
121 const alive = (await $.fs.list(`${dir}/alive`))
122 .filter(entry => now - entry.mtimeMs < ALIVE_MS)
123 .map(entry => entry.name)
124 .sort()
125
126 return alive[0] === selfId
127 }
128
129 const loadAgents = async (now: number, names: string): Promise<Agent[]> => {
130 const cache = await readJson<AgentsCache>($, `${dir}/agents.json`)
131 if (cache !== undefined && cache.files === names && now - cache.at < CACHE_MS) {
132 return cache.agents
133 }
134 const out = await $.process.run(agentsArgv(platform), { timeoutMs: 15000 })
135 if (out.exitCode !== 0) throw new Error(out.stderr.trim() || `exit ${out.exitCode}`)
136 const listed = JSON.parse(out.stdout) as Agent[]
137 await $.fs.write(`${dir}/agents.json`, JSON.stringify({ at: now, files: names, agents: listed }))
138
139 return listed
140 }
141
142 const refresh = async () => {
143 if (isRunning) return
144 isRunning = true
145 try {
146 const now = await $.clock.now()
147 const isLead = await isLeader(now)
148 const names = (await $.fs.list(`${home}/.claude/sessions`))
149 .map(entry => entry.name)
150 .filter(name => name.endsWith('.json'))
151 .sort()
152 .join(',')
153 if (tick % LIST_EVERY === 0 || names !== files) {
154 agents = await loadAgents(now, names)
155 files = names
156 }
157 tick += 1
158
159 const fresh = await collect($, home, selfId, agents)
160 const changes = changesOf(prev, fresh)
161 for (const row of changes.waiting) {
162 if (!row.isSelf) $.ui.toast(`${row.name}: ждёт решения`)
163 }
164 for (const row of changes.finished) $.ui.toast(`${row.name}: закончила работу`)
165 // One chime per tick, a decision before a finished turn: clips are not
166 // queued, two would sound together.
167 const sound =
168 changes.waiting.length > 0 ? SOUND_WAITING : changes.finished.length > 0 ? SOUND_FINISHED : undefined
169 if (sound !== undefined && isLead) void $.audio.play({ asset: sound }).catch(() => undefined)
170 prev = new Map(fresh.map(row => [row.id, row.state]))
171
172 const waitingCount = fresh.filter(row => row.state === 'waiting' && !row.isSelf).length
173 $.ui.status(waitingCount > 0 ? `Ждут решения: ${waitingCount}` : undefined)
174
175 const latest = await readJson<SessionsPanelLimits>($, `${dir}/limits.json`)
176 await update($, limits, () => latest ?? null)
177 await update($, rows, () => fresh)
178 await update($, error, () => null)
179 } catch (err) {
180 await update($, error, () => String(err instanceof Error ? err.message : err))
181 } finally {
182 isRunning = false
183 }
184 }
185
186 void refresh()
187 $.clock.every(TICK_MS, () => void refresh())
188 void $.ui.open({ id: PANE, title: TITLE })
189
190 return next(e)
191 })
192
193 on('command.run', { command: 'sessions' }, async $ => {
194 const opened = await $.ui.open({ id: PANE, title: TITLE })
195
196 return { text: opened.isPlaced ? 'Панель сессий открыта.' : 'Панель сессий не открылась.' }
197 })
198
199 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
200 const { Box, Text } = $.ui.resolve(e)
201 const list = await read($, rows)
202 const failed = await read($, error)
203 const usage = await read($, limits)
204 const now = await $.clock.now()
205 const groups = groupRows(list, now)
206 const windows = usage === null ? [] : limitRows(usage.windows, now)
207
208 return (
209 <Box flexDirection="column">
210 {windows.length > 0 && usage !== null && (
211 <Box key="limits" flexDirection="column" marginBottom={1}>
212 <Box>
213 <Text bold>Лимиты</Text>
214 <Text dimColor>
215 {' · обновлено '}
216 {now - usage.at < 60000 ? 'только что' : `${formatAge(now - usage.at)} назад`}
217 </Text>
218 </Box>
219 {windows.map(window => (
220 <Box key={window.kind} flexDirection="column">
221 <Box>
222 <Box width={8}>
223 <Text>{window.label}</Text>
224 </Box>
225 <Text color={window.color}>{window.bar} </Text>
226 <Text bold color={window.color}>
227 {window.percent}%
228 </Text>
229 </Box>
230 {window.reset !== '' && (
231 <Box paddingLeft={2}>
232 <Text dimColor wrap="truncate-end">
233 {window.reset}
234 </Text>
235 </Box>
236 )}
237 </Box>
238 ))}
239 </Box>
240 )}
241 {failed !== null && <Text color="error">Не удалось получить сессии: {failed}</Text>}
242 {list.length === 0 && failed === null && <Text dimColor>Ищу сессии…</Text>}
243 {groups.map(group => (
244 <Box key={group.state} flexDirection="column" marginBottom={1}>
245 <Text bold color={group.color}>
246 {group.title}: {group.total}
247 </Text>
248 {group.rows.map(row => (
249 <Box key={row.id}>
250 <Text color={group.color}>{group.glyph} </Text>
251 <Text bold={row.isSelf} wrap="truncate-end">
252 {row.isSelf ? `${row.name} (эта)` : row.name}
253 </Text>
254 <Text dimColor wrap="truncate-end">
255 {' · '}
256 {detailOf(row, now)}
257 </Text>
258 </Box>
259 ))}
260 {group.folded > 0 && <Text dimColor>и ещё {group.folded} без дела дольше 12 ч</Text>}
261 </Box>
262 ))}
263 </Box>
264 )
265 })
266}
267hooks/sessions.ts 207 lines1import type { SessionsPanelRow, SessionsPanelState } from '../types'
2
3// One entry of `claude agents --json`.
4export type Agent = {
5 pid: number
6 sessionId: string
7 name?: string
8 cwd: string
9 status: string
10 startedAt: number
11}
12
13// ~/.claude/sessions/<pid>.json: the engine's own registry, updated live.
14export type Registry = {
15 status?: string
16 name?: string
17 statusUpdatedAt?: number
18 entrypoint?: string
19}
20
21// ~/.claude-control/events/<pid>.json: the last hook event Claude Control saw.
22export type ControlEvent = {
23 event?: string
24 ts?: number
25 transcript_path?: string
26}
27
28// The event's `ts` is whole seconds, so a transcript write within that second
29// still belongs to the request itself.
30const GRACE_MS = 1000
31
32export const IDLE_FOLD_MS = 12 * 60 * 60 * 1000
33
34export const GROUPS: readonly {
35 state: SessionsPanelState
36 title: string
37 glyph: string
38 color: string
39}[] = [
40 { state: 'waiting', title: 'Ждут решения', glyph: '●', color: 'warning' },
41 { state: 'busy', title: 'Работают', glyph: '●', color: 'success' },
42 { state: 'other', title: 'Другое', glyph: '◌', color: 'subtle' },
43 { state: 'idle', title: 'Свободны', glyph: '○', color: 'inactive' },
44]
45
46const VIA: Record<string, string> = {
47 'claude-desktop': 'desktop',
48 'claude-vscode': 'vscode',
49 cli: 'терминал',
50}
51
52export function projectOf(cwd: string): string {
53 return cwd.split('/').filter(Boolean).at(-1) ?? cwd
54}
55
56// A busy session whose last event is PermissionRequest waits for a decision
57// until the transcript moves on: an answered request appends the tool result.
58export function stateOf(
59 agent: Agent,
60 reg: Registry | undefined,
61 ev: ControlEvent | undefined,
62 transcriptMtimeMs: number | undefined,
63): SessionsPanelState {
64 const status = reg?.status ?? agent.status
65 if (status === 'idle') return 'idle'
66 if (status !== 'busy') return 'other'
67 if (ev?.event !== 'PermissionRequest' || ev.ts === undefined) return 'busy'
68 if (transcriptMtimeMs !== undefined && transcriptMtimeMs > ev.ts * 1000 + GRACE_MS) return 'busy'
69
70 return 'waiting'
71}
72
73export function rowOf(
74 agent: Agent,
75 reg: Registry | undefined,
76 state: SessionsPanelState,
77 selfId: string,
78): SessionsPanelRow {
79 const project = projectOf(agent.cwd)
80
81 return {
82 id: agent.sessionId,
83 name: reg?.name || agent.name || project,
84 project,
85 state,
86 since: reg?.statusUpdatedAt ?? agent.startedAt,
87 via: VIA[reg?.entrypoint ?? ''] ?? reg?.entrypoint ?? '',
88 isSelf: agent.sessionId === selfId,
89 }
90}
91
92// The project is left out where the session's name already says it.
93export function detailOf(row: SessionsPanelRow, now: number): string {
94 const norm = (s: string) => s.toLowerCase().replaceAll('_', '-')
95 const parts = norm(row.name).includes(norm(row.project)) ? [] : [row.project]
96 parts.push(formatAge(now - row.since))
97 if (row.via !== '') parts.push(row.via)
98
99 return parts.join(' · ')
100}
101
102export function formatAge(ms: number): string {
103 const min = Math.floor(ms / 60000)
104 if (min < 1) return 'сейчас'
105 if (min < 60) return `${min} мин`
106 const hours = Math.floor(min / 60)
107 if (hours < 24) return `${hours} ч`
108
109 return `${Math.floor(hours / 24)} дн`
110}
111
112// Rows of each state, newest change first; idle ones quiet for longer than
113// IDLE_FOLD_MS are only counted.
114export function groupRows(rows: readonly SessionsPanelRow[], now: number) {
115 return GROUPS.map(group => {
116 const own = rows
117 .filter(row => row.state === group.state)
118 .sort((a, b) => b.since - a.since)
119 const shown =
120 group.state === 'idle' ? own.filter(row => now - row.since < IDLE_FOLD_MS) : own
121
122 return { ...group, rows: shown, folded: own.length - shown.length, total: own.length }
123 }).filter(group => group.total > 0)
124}
125
126// Sessions that changed state since the last check: started waiting for a
127// decision, or went idle after working (a turn ended). Nothing is reported on
128// the first check, when there is no `prev`.
129export function changesOf(
130 prev: ReadonlyMap<string, SessionsPanelState> | undefined,
131 fresh: readonly SessionsPanelRow[],
132) {
133 const known = prev === undefined ? [] : fresh.filter(row => prev.has(row.id))
134 const waiting = known.filter(row => row.state === 'waiting' && prev?.get(row.id) !== 'waiting')
135 const finished = known.filter(row => {
136 const was = prev?.get(row.id)
137 return row.state === 'idle' && (was === 'busy' || was === 'waiting')
138 })
139
140 return { waiting, finished }
141}
142
143const LIMIT_LABELS: Record<string, string> = {
144 five_hour: 'Сессия',
145 seven_day: 'Неделя',
146}
147
148// The 5-hour window first, then the week, then any other window as named.
149export function limitRows(windows: readonly { kind: string; percentUsed: number; resetsAt?: string }[], now: number) {
150 const order = (kind: string) => (kind === 'five_hour' ? 0 : kind === 'seven_day' ? 1 : 2)
151
152 return [...windows]
153 .sort((a, b) => order(a.kind) - order(b.kind))
154 .map(window => {
155 const resetsAt = window.resetsAt === undefined ? NaN : Date.parse(window.resetsAt)
156
157 return {
158 kind: window.kind,
159 label: LIMIT_LABELS[window.kind] ?? window.kind,
160 percent: Math.round(window.percentUsed),
161 bar: barOf(window.percentUsed),
162 color: window.percentUsed >= 90 ? 'error' : window.percentUsed >= 70 ? 'warning' : 'success',
163 reset: Number.isNaN(resetsAt) ? '' : `сброс ${formatLeft(resetsAt - now)}`,
164 }
165 })
166}
167
168export function barOf(percent: number, cells = 10): string {
169 const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
170
171 return '█'.repeat(filled) + '░'.repeat(cells - filled)
172}
173
174export function formatLeft(ms: number): string {
175 if (ms <= 0) return 'уже прошёл'
176 const min = Math.ceil(ms / 60000)
177 if (min < 60) return `через ${min} мин`
178 const hours = Math.floor(min / 60)
179 if (hours < 24) return `через ${hours} ч ${min % 60} мин`
180
181 return `через ${Math.floor(hours / 24)} дн ${hours % 24} ч`
182}
183
184export type Platform = 'mac' | 'windows' | 'linux'
185
186// Where the sessions running the mod share their state: the platform's own
187// cache folder.
188export function cacheDirOf(
189 platform: Platform,
190 home: string,
191 name: string,
192 localAppData?: string,
193 xdgCache?: string,
194): string {
195 if (platform === 'windows') return `${localAppData ?? `${home}/AppData/Local`}/${name}`
196 if (platform === 'mac') return `${home}/Library/Caches/${name}`
197
198 return `${xdgCache ?? `${home}/.cache`}/${name}`
199}
200
201// Run with no shell, Windows finds no `claude.cmd`: there it goes through cmd.exe.
202export function agentsArgv(platform: Platform): string[] {
203 return platform === 'windows'
204 ? ['cmd.exe', '/d', '/s', '/c', 'claude agents --json']
205 : ['claude', 'agents', '--json']
206}
207types/index.d.ts 27 lines1export type SessionsPanelState = 'waiting' | 'busy' | 'idle' | 'other'
2
3export type SessionsPanelRow = {
4 id: string
5 name: string
6 project: string
7 state: SessionsPanelState
8 since: number
9 via: string
10 isSelf: boolean
11}
12
13export type SessionsPanelLimits = {
14 at: number
15 windows: { kind: string; percentUsed: number; resetsAt?: string }[]
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'sessions-panel': {
21 rows: SessionsPanelRow[]
22 error: string | null
23 limits: SessionsPanelLimits | null
24 }
25 }
26}
27