A side pane listing the files touched, commands run and agents started this session.

A Claude Code mod that keeps a live side pane of what happened this session, in three boxes:
+ created, ~ edited or · read. A file keeps its strongest mark.● running, ✓ ok or ✗ failed, with how long it took.bg) and how long it took. Marks are ● running, ✓ done, ✗ failed or ■ stopped.The header line counts all three and shows a red failed count when a command or agent has failed. Colours come from your terminal theme.
The pane opens when a session starts. If your terminal is narrower than 110 columns it waits undrawn; open it at any width with:
/session-notes
/session-notes clear empties all three boxes.
At the prompt of a Claude Code terminal session:
/plugin install session-notes --marketplace pongsixtyfour/mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install session-notes --marketplace pongsixtyfour/mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with pongsixtyfour/mods, then Add plugin.
Read, Edit, Write and Bash, and agent starts and ends. It ignores NotebookEdit, Glob and Grep.claude plugin validate .
claude plugin test .
The tests cover file marks and the strongest-mark rule, command ok and failed, clear, the empty pane, calls made inside a subagent, agent running, done, stopped, failed and refused, an agent that ends before its spawn call returns, the model shown, and old saved notes being dropped.
hooks/register.tsx 285 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { NoteAgentStatus, NoteFile, SessionNotes } from '../types'
5import { empty, whole } from './state'
6
7const PANE = 'session-notes'
8const MAX_COMMANDS = 100
9const MAX_AGENTS = 50
10const notes = atom({ plugin: 'session-notes', key: 'notes' } as const, empty())
11
12const edit = ($: Parameters<typeof update>[0], change: (state: SessionNotes) => SessionNotes) =>
13 update($, notes, state => change(whole(state)))
14
15const RANK: Record<NoteFile['kind'], number> = { read: 0, edited: 1, created: 2 }
16
17const split = (path: string) => {
18 const parts = path.split('/')
19 const dir = parts.slice(-3, -1).join('/')
20
21 return { dir: dir === '' ? '' : `${dir}/`, name: parts[parts.length - 1] ?? path }
22}
23
24const shortModel = (model: string) => model.replace(/^claude-/, '')
25
26const took = (ms: number) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
27
28const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
29
30const noteFile = (path: string, kind: NoteFile['kind']) => (state: SessionNotes) => {
31 const seen = state.files.find(file => file.path === path)
32 if (seen === undefined) return { ...state, files: [...state.files, { path, kind }] }
33 if (RANK[kind] <= RANK[seen.kind]) return state
34
35 return { ...state, files: state.files.map(file => (file === seen ? { path, kind } : file)) }
36}
37
38export const register: Register = on => {
39 let spawned = 0
40
41 on('session.start', async ($, e, next) => {
42 await $.command.register({
43 name: 'session-notes',
44 description: 'Show the files touched and commands run this session (clear: reset)',
45 })
46 void $.ui.open({ id: PANE, title: 'Session notes' })
47
48 return next(e)
49 })
50
51 on('command.run', { command: 'session-notes' }, async ($, e) => {
52 if (e.args.trim() === 'clear') {
53 await edit($, empty)
54
55 return { text: 'Session notes cleared.' }
56 }
57 await $.ui.open({ id: PANE, title: 'Session notes' })
58
59 return { text: 'Session notes pane opened.' }
60 })
61
62 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
63 await edit($, noteFile(e.file_path, 'read'))
64
65 return next(e)
66 })
67
68 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
69 await edit($, noteFile(e.file_path, 'edited'))
70
71 return next(e)
72 })
73
74 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
75 await edit($, noteFile(e.file_path, 'created'))
76
77 return next(e)
78 })
79
80 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
81 const startedAt = Date.now()
82 const entry = { id: e.tool_use_id, command: e.command, status: 'running' as const, startedAt }
83 await edit($, state => ({
84 ...state,
85 commands: [...state.commands, entry].slice(-MAX_COMMANDS),
86 }))
87 const ran = await next(e)
88 const status = ran.deny === undefined && ran.isError !== true ? 'ok' : 'failed'
89 await edit($, state => ({
90 ...state,
91 commands: state.commands.map(one =>
92 one.id === entry.id ? { ...one, status, ms: Date.now() - startedAt } : one,
93 ),
94 }))
95
96 return ran
97 })
98
99 on('agent.spawn', async ($, e, next) => {
100 const id = `${e.tool_use_id}|${Date.now()}|${spawned++}`
101 await edit($, state => ({
102 ...state,
103 agents: [
104 ...state.agents,
105 {
106 id,
107 type: e.subagentType,
108 description: e.description,
109 model: e.model ?? e.parentModel,
110 isBackground: e.background,
111 status: 'running' as const,
112 },
113 ].slice(-MAX_AGENTS),
114 }))
115 const started = await next(e)
116 await edit($, state => {
117 if (started.deny !== undefined) {
118 return { ...state, agents: state.agents.filter(one => one.id !== id) }
119 }
120 const early = state.ended.find(one => one.agentId === started.agentId)
121
122 return {
123 ...state,
124 ended: state.ended.filter(one => one !== early),
125 agents: state.agents.map(one =>
126 one.id === id
127 ? {
128 ...one,
129 agentId: started.agentId,
130 model: started.model,
131 ...(early && { status: early.status, ms: early.ms }),
132 }
133 : one,
134 ),
135 }
136 })
137
138 return started
139 })
140
141 on('turn.complete', async ($, e, next) => {
142 const answered = await next(e)
143 const agentId = e.agentId
144 if (agentId === undefined) return answered
145
146 const status: NoteAgentStatus =
147 e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
148 await edit($, state => {
149 if (!state.agents.some(one => one.agentId === agentId)) {
150 return { ...state, ended: [...state.ended, { agentId, status, ms: e.durationMs }] }
151 }
152
153 return {
154 ...state,
155 agents: state.agents.map(one =>
156 one.agentId === agentId ? { ...one, status, ms: e.durationMs } : one,
157 ),
158 }
159 })
160
161 return answered
162 })
163
164 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
165 const { Box, Text } = $.ui.resolve(e)
166 const { files, commands, agents } = whole(await read($, notes))
167 const room = Math.max(2, Math.floor(((e.viewport?.rows ?? 24) - 18) / 3))
168 const failed =
169 commands.filter(one => one.status === 'failed').length +
170 agents.filter(one => one.status === 'failed').length
171 const marks = { created: '+', edited: '~', read: '·' } as const
172 const tints = { created: 'success', edited: 'warning', read: 'inactive' } as const
173 const states = { running: '●', ok: '✓', failed: '✗' } as const
174 const colors = { running: 'warning', ok: 'success', failed: 'error' } as const
175 const agentMarks = { running: '●', done: '✓', failed: '✗', stopped: '■' } as const
176 const agentTints = {
177 running: 'warning',
178 done: 'success',
179 failed: 'error',
180 stopped: 'inactive',
181 } as const
182
183 return (
184 <Box flexDirection="column">
185 <Box gap={1} paddingX={1}>
186 <Text bold color="claude">
187 Session notes
188 </Text>
189 <Text dimColor>
190 {plural(files.length, 'file')} · {plural(commands.length, 'command')} ·{' '}
191 {plural(agents.length, 'agent')}
192 </Text>
193 {failed > 0 && <Text color="error">{failed} failed</Text>}
194 </Box>
195
196 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
197 <Text bold>Files</Text>
198 {files.length === 0 && (
199 <Text dimColor italic>
200 Nothing touched yet.
201 </Text>
202 )}
203 {files.slice(-room).map(file => {
204 const { dir, name } = split(file.path)
205
206 return (
207 <Box key={`file:${file.path}`} gap={1}>
208 <Text color={tints[file.kind]}>{marks[file.kind]}</Text>
209 <Box flexShrink={1}>
210 <Text dimColor>{dir}</Text>
211 <Text
212 bold={file.kind !== 'read'}
213 dimColor={file.kind === 'read'}
214 wrap="truncate-end"
215 >
216 {name}
217 </Text>
218 </Box>
219 </Box>
220 )
221 })}
222
223 <Box marginTop={1} gap={2}>
224 <Box gap={1}>
225 <Text color={tints.created}>{marks.created}</Text>
226 <Text color="subtle">new</Text>
227 </Box>
228 <Box gap={1}>
229 <Text color={tints.edited}>{marks.edited}</Text>
230 <Text color="subtle">edited</Text>
231 </Box>
232 <Box gap={1}>
233 <Text color={tints.read}>{marks.read}</Text>
234 <Text color="subtle">read</Text>
235 </Box>
236 </Box>
237 </Box>
238
239 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
240 <Text bold>Commands</Text>
241 {commands.length === 0 && (
242 <Text dimColor italic>
243 Nothing run yet.
244 </Text>
245 )}
246 {commands.slice(-room).map(one => (
247 <Box key={`cmd:${one.id}`} gap={1}>
248 <Text color={colors[one.status]}>{states[one.status]}</Text>
249 <Box flexGrow={1} flexShrink={1}>
250 <Text color={one.status === 'failed' ? 'error' : undefined} wrap="truncate-end">
251 {one.command.split('\n')[0]}
252 </Text>
253 </Box>
254 {one.ms !== undefined && <Text dimColor>{took(one.ms)}</Text>}
255 </Box>
256 ))}
257 </Box>
258
259 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
260 <Text bold>Agents</Text>
261 {agents.length === 0 && (
262 <Text dimColor italic>
263 None started.
264 </Text>
265 )}
266 {agents.slice(-room).map(one => (
267 <Box key={`agent:${one.id}`} gap={1}>
268 <Text color={agentTints[one.status]}>{agentMarks[one.status]}</Text>
269 <Text bold>{one.type}</Text>
270 {one.model !== '' && <Text color="suggestion">{shortModel(one.model)}</Text>}
271 <Box flexGrow={1} flexShrink={1}>
272 <Text dimColor wrap="truncate-end">
273 {one.description}
274 </Text>
275 </Box>
276 {one.isBackground && one.status === 'running' && <Text dimColor>bg</Text>}
277 {one.ms !== undefined && <Text dimColor>{took(one.ms)}</Text>}
278 </Box>
279 ))}
280 </Box>
281 </Box>
282 )
283 })
284}
285hooks/state.ts 16 lines1import type { SessionNotes } from '../types'
2
3// Bump when the shape of SessionNotes changes: notes saved under another version are dropped.
4export const VERSION = 2
5
6export const empty = (): SessionNotes => ({
7 version: VERSION,
8 files: [],
9 commands: [],
10 agents: [],
11 ended: [],
12})
13
14export const whole = (state: Partial<SessionNotes> | undefined): SessionNotes =>
15 state?.version === VERSION ? { ...empty(), ...state } : empty()
16types/index.d.ts 34 lines1export type NoteFile = { path: string; kind: 'read' | 'edited' | 'created' }
2export type NoteCommand = {
3 id: string
4 command: string
5 status: 'running' | 'ok' | 'failed'
6 startedAt: number
7 ms?: number
8}
9export type NoteAgentStatus = 'running' | 'done' | 'failed' | 'stopped'
10export type NoteAgent = {
11 id: string
12 agentId?: string
13 type: string
14 description: string
15 model: string
16 isBackground: boolean
17 status: NoteAgentStatus
18 ms?: number
19}
20export type NoteAgentEnd = { agentId: string; status: NoteAgentStatus; ms: number }
21export type SessionNotes = {
22 version: number
23 files: NoteFile[]
24 commands: NoteCommand[]
25 agents: NoteAgent[]
26 ended: NoteAgentEnd[]
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-notes': { notes: SessionNotes }
32 }
33}
34