A side pane listing the Claude Code sessions running on this machine, all folders, with each one's status (working or idle) and last activity.

Small mods for Claude Code, built on its function-hooks plugin API.
| Mod | What it does |
|---|---|
| turn-pulse | A band above the prompt summarizing the last turn, plus a live status line while Claude works. |
| notes | /note saves chat text to a notes list shared across projects; /notes opens it in a side pane. |
| sessions | /sessions opens a side pane listing the sessions running on this machine and whether each is working or idle. |
Early access. The mod API these use is marked EARLY ACCESS and may change between Claude Code releases. Built and tested on Claude Code 2.1.293, on Windows, in the desktop app's Code tab.
In a terminal Claude Code session, type one line per mod:
/plugin install turn-pulse --marketplace ilya-paskhover/claude-code-mods
/plugin install notes --marketplace ilya-paskhover/claude-code-mods
/plugin install sessions --marketplace ilya-paskhover/claude-code-mods
Answer y to add the marketplace (asked only the first time), then pick a scope. The user scope makes the mod load in every session, including sessions the desktop app starts. /plugin is not available inside the desktop app's Code tab, so install from a terminal.

A band labeled ◆ last turn sits above the prompt after each turn:
✓ N ok, and ✗ N failed (Tool ×n) when something failed.5h +1% · week +0.3%.The status line shows ▶ Tool · N ok · ✗ N failed live during a turn, and session: 5h +6% · week +2% between turns. A toast appears when a turn takes 60 seconds or more. /pulse hides or shows the band.
Limits:

/note <text> saves the typed text. With text selected in the transcript, /note <comment> saves the selection with the typed text as its comment./notes opens a Notes side pane: notes render as Markdown, long ones fold behind More/Less, and each has a ☐/☑ done toggle, Delete, and Jump (scroll to the message it came from; same session only). Show/Hide done and Clear done act on the whole list.<pasted_content> wrapper stripped.Limits:
/note there needs pasted or typed text, and Jump never appears. Selection is documented to work in the fullscreen terminal; I have not verified that. Tracked in #1./sessions opens a Sessions side pane listing every Claude Code session running on this machine, from any folder:
Limits:
~/.claude/sessions/ (or $CLAUDE_CONFIG_DIR/sessions/), not from the mod API. That format is undocumented and could change in any release; if no file can be read, the pane says so instead of showing a wrong list.Each mod is a plugin folder under plugins/. To run a working copy instead of the installed one, name the folders in the env block of ~/.claude/settings.json (separated by ; on Windows, : elsewhere) and uninstall the marketplace copy so the mod does not load twice:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/plugins/turn-pulse;/path/to/claude-code-mods/plugins/notes"
}
}
Interactive sessions watch those folders and reload a mod when its files are saved. Checks, per mod folder:
claude plugin validate plugins/notes
claude plugin test plugins/notes
Each mod's tsconfig.json extends the type declarations the engine writes into .claude-plugin/types/ when it loads the mod (gitignored), so tsc -p plugins/notes works once the mod has loaded once.
hooks/register.tsx 133 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { SessionRow } from '../types'
5import { ago, folderName, look, parseSession, sortRows } from './parse'
6
7// Claude Code writes one <pid>.json per running session into its config
8// folder's sessions/ directory. That file is the engine's own, not mod API.
9const PANE = 'sessions'
10const PANE_TITLE = 'Sessions'
11const REFRESH_MS = 5_000
12
13const rows = atom({ plugin: 'sessions', key: 'rows' } as const, [])
14const sessionId = atom({ plugin: 'sessions', key: 'sessionId' } as const, '')
15const now = atom({ plugin: 'sessions', key: 'now' } as const, 0)
16const error = atom({ plugin: 'sessions', key: 'error' } as const, '')
17
18async function sessionsDir($: EngineInterface) {
19 const config = await $.env.get('CLAUDE_CONFIG_DIR')
20 if (config !== undefined && config !== '') return `${config.replace(/[\\/]+$/, '')}/sessions`
21 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
22 return home === undefined ? undefined : `${home.replace(/[\\/]+$/, '')}/.claude/sessions`
23}
24
25async function refresh($: EngineInterface) {
26 const at = await $.clock.now()
27 const dir = await sessionsDir($)
28 let found: SessionRow[] = []
29 let problem = ''
30 try {
31 if (dir === undefined) throw new Error('no home folder')
32 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
33 for (const file of files) {
34 const row = await $.fs
35 .read(`${dir}/${file.name}`)
36 .then(parseSession)
37 .catch(() => undefined)
38 if (row !== undefined) found.push(row)
39 }
40 if (files.length > 0 && found.length === 0) {
41 problem = "Couldn't read any session file: Claude Code may have changed their format."
42 }
43 } catch {
44 problem = `No session folder found${dir === undefined ? '' : ` at ${dir}`}.`
45 }
46 found = sortRows(found)
47 await update($, rows, () => found)
48 await update($, error, () => problem)
49 await update($, now, () => at)
50}
51
52// Module variable on purpose: a reload drops timers, and session.start
53// restarts the refresh when the pane is still up.
54let ticking: Timer | undefined
55
56const isPaneUp = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE)
57
58function startTicking($: EngineInterface) {
59 if (ticking !== undefined) return
60 ticking = $.clock.every(REFRESH_MS, async () => {
61 if (!(await isPaneUp($))) {
62 ticking?.cancel()
63 ticking = undefined
64 return
65 }
66 await refresh($)
67 })
68}
69
70export const register: Register = on => {
71 on('session.start', async ($, e, next) => {
72 await $.command.register({
73 name: 'sessions',
74 description: 'Open the Sessions pane: running sessions and their status',
75 immediate: true,
76 })
77 const id = await $.session.id()
78 await update($, sessionId, () => id)
79 if (await isPaneUp($).catch(() => false)) {
80 await refresh($)
81 startTicking($)
82 }
83
84 return next(e)
85 })
86
87 on('command.run', { command: 'sessions' }, async $ => {
88 await refresh($)
89 await $.ui.open({ id: PANE, title: PANE_TITLE })
90 startTicking($)
91
92 return { text: 'Sessions pane opened.' }
93 })
94
95 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
96 const { Box, Button, Text } = $.ui.resolve(e)
97 const list = await read($, rows)
98 const current = await read($, sessionId)
99 const at = await read($, now)
100 const problem = await read($, error)
101 const working = list.filter(row => row.status === 'busy').length
102
103 return (
104 <Box flexDirection="column" gap={1}>
105 <Box flexDirection="row" gap={1}>
106 <Text color="claude" bold>{list.length} running</Text>
107 <Text dimColor>· {working} working</Text>
108 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
109 </Box>
110 {problem !== '' ? <Text color="error">{problem}</Text> : null}
111 {problem === '' && list.length === 0 ? <Text dimColor>No running sessions found.</Text> : null}
112 {list.map(row => {
113 const { dot, label, color, isDim } = look(row, at)
114 return (
115 <Box key={row.id} flexDirection="column">
116 <Box flexDirection="row" gap={1}>
117 <Text color={color} dimColor={isDim}>{dot}</Text>
118 <Text bold={row.id === current} dimColor={isDim}>{row.name}</Text>
119 </Box>
120 <Box flexDirection="row" gap={1}>
121 <Text color={color} dimColor={isDim}> {label}</Text>
122 <Text dimColor>· {ago(at - row.updatedAt)}</Text>
123 <Text color="suggestion" dimColor={isDim}>· {folderName(row.cwd)}</Text>
124 {row.id === current ? <Text dimColor>· this session</Text> : null}
125 </Box>
126 </Box>
127 )
128 })}
129 </Box>
130 )
131 })
132}
133hooks/parse.ts 68 lines1import type { SessionRow } from '../types'
2
3// A busy session whose file has not changed for this long may have died
4// without cleaning up; it is drawn dimmed with a question mark.
5export const STALE_MS = 30 * 60_000
6
7export const folderName = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
8
9// Claude Code's own session file, not part of the mod API: read defensively.
10export const parseSession = (text: string): SessionRow | undefined => {
11 let raw: unknown
12 try {
13 raw = JSON.parse(text)
14 } catch {
15 return undefined
16 }
17 if (typeof raw !== 'object' || raw === null) return undefined
18 const o = raw as Record<string, unknown>
19 const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : undefined)
20 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
21
22 const id = str(o.sessionId)
23 const pid = num(o.pid)
24 if (id === undefined || pid === undefined) return undefined
25 const cwd = str(o.cwd) ?? ''
26 return {
27 id,
28 pid,
29 cwd,
30 name: str(o.name) ?? (cwd !== '' ? folderName(cwd) : id.slice(0, 8)),
31 status: str(o.status) ?? 'unknown',
32 updatedAt: num(o.updatedAt) ?? num(o.statusUpdatedAt) ?? num(o.startedAt) ?? 0,
33 entrypoint: str(o.entrypoint),
34 }
35}
36
37// Working sessions first, then the most recently active; one row per session.
38export const sortRows = (rows: readonly SessionRow[]): SessionRow[] => {
39 const newest = new Map<string, SessionRow>()
40 for (const row of rows) {
41 const seen = newest.get(row.id)
42 if (seen === undefined || row.updatedAt > seen.updatedAt) newest.set(row.id, row)
43 }
44 return [...newest.values()].sort((a, b) => {
45 const busy = Number(b.status === 'busy') - Number(a.status === 'busy')
46 return busy !== 0 ? busy : b.updatedAt - a.updatedAt
47 })
48}
49
50export const ago = (ms: number) => {
51 const minutes = Math.floor(Math.max(0, ms) / 60_000)
52 if (minutes < 1) return 'now'
53 if (minutes < 60) return `${minutes}m`
54 const hours = Math.floor(minutes / 60)
55 return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
56}
57
58export type Look = { dot: string; label: string; color?: string; isDim: boolean }
59
60export const look = (row: SessionRow, now: number): Look => {
61 if (row.status === 'busy') {
62 const isStale = now - row.updatedAt > STALE_MS
63 return { dot: '●', label: isStale ? 'working?' : 'working', color: 'warning', isDim: isStale }
64 }
65 if (row.status === 'idle') return { dot: '○', label: 'idle', color: 'success', isDim: false }
66 return { dot: '·', label: row.status, isDim: true }
67}
68types/index.d.ts 18 lines1// One running session, as Claude Code's own ~/.claude/sessions/<pid>.json
2// describes it.
3export type SessionRow = {
4 id: string
5 pid: number
6 name: string
7 cwd: string
8 status: string
9 updatedAt: number
10 entrypoint?: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 sessions: { rows: SessionRow[]; sessionId: string; now: number; error: string }
16 }
17}
18