One board of every Claude Code session on this machine: which need you, which finished, which are working, plus a log of the day.

One board of every Claude Code session on your machine. It records quietly; open the pane with /board when you want it. When you run many sessions at once, notifications flash and vanish; the board stays put and answers one question: which sessions are waiting on me?
Sessions are grouped:
The session you are in is marked with ▸. In the desktop app each session shows under its sidebar title, so you can find it in the sidebar; a mod cannot switch sessions for you, and the app decides where the pane docks.
/board — open the board pane/board-log — today's timeline across every session, oldest firstEvery session with the mod installed writes its own small JSON file to ~/.claude/session-board/<session-id>.json (state, project, first prompt as its label, last note, and up to 200 events). Each pane reads that folder every five seconds. Nothing leaves your machine. Files older than three days are ignored; delete the folder at any time to start fresh.
At the prompt of a terminal session:
/plugin install session-board --marketplace iiljohne/claude-session-board
From a shell:
claude plugin install session-board --marketplace iiljohne/claude-session-board
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with iiljohne/claude-session-board, then Add plugin.
Install at the user scope so it runs in every session.
hooks/register.tsx 318 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardEvent, BoardSession, BoardState } from '../types'
5
6// Every session on this machine writes its own file under ~/.claude/session-board/;
7// the pane in any session reads them all, so one glance covers every stream.
8
9const PANE = 'session-board'
10const PANE_ARGS = { id: PANE, title: 'Sessions', columns: 48 }
11const REFRESH_MS = 5_000
12const TITLE_MS = 30_000
13const SHOW_ENDED_MS = 12 * 60 * 60 * 1000
14const SHOW_ANY_MS = 3 * 24 * 60 * 60 * 1000
15const QUIET_MS = 2 * 60 * 60 * 1000
16const MAX_EVENTS = 200
17
18const sessions = atom({ plugin: 'session-board', key: 'sessions' } as const, [])
19const selfId = atom({ plugin: 'session-board', key: 'selfId' } as const, '')
20
21const ORDER: BoardState[] = ['needs-you', 'your-turn', 'working', 'ended']
22const HEADINGS: Record<BoardState, string> = {
23 'needs-you': 'Needs you',
24 'your-turn': 'Finished, your turn',
25 working: 'Working',
26 ended: 'Ended today',
27}
28const COLORS: Record<BoardState, string> = {
29 'needs-you': 'warning',
30 'your-turn': 'success',
31 working: 'suggestion',
32 ended: 'inactive',
33}
34
35const oneLine = (text: string, max: number) => {
36 const line = text.replace(/\s+/g, ' ').trim()
37 return line.length > max ? `${line.slice(0, max - 1)}…` : line
38}
39
40const ago = (ms: number) => {
41 const minutes = Math.floor(ms / 60_000)
42 if (minutes < 1) return 'now'
43 if (minutes < 60) return `${minutes}m`
44 const hours = Math.floor(minutes / 60)
45 return hours < 24 ? `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ''}` : `${Math.floor(hours / 24)}d`
46}
47
48const time = (at: number) => {
49 const d = new Date(at)
50 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
51}
52
53const lastPart = (path: string) => path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
54
55let dir = ''
56let me: BoardSession | undefined
57let writes = Promise.resolve()
58
59async function boardDir($: EngineInterface) {
60 if (dir) return dir
61 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
62 dir = `${home.replace(/[\\/]$/, '')}/.claude/session-board`
63 return dir
64}
65
66async function loadAll($: EngineInterface) {
67 const folder = await boardDir($)
68 if (!(await $.fs.exists(folder))) return []
69 const now = await $.clock.now()
70 const entries = await $.fs.list(folder)
71 const found: BoardSession[] = []
72 for (const entry of entries) {
73 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
74 if (now - entry.mtimeMs > SHOW_ANY_MS) continue
75 try {
76 found.push(JSON.parse(await $.fs.read(`${folder}/${entry.name}`)) as BoardSession)
77 } catch {
78 // A file mid-write or hand-edited: skip it this round.
79 }
80 }
81 return found
82}
83
84async function refresh($: EngineInterface) {
85 const found = await loadAll($)
86 await update($, sessions, () => found)
87}
88
89async function write($: EngineInterface, state: BoardState, note: string) {
90 if (!me) return
91 const at = await $.clock.now()
92 const event: BoardEvent = { at, state, note: oneLine(note, 160) }
93 me = {
94 ...me,
95 state,
96 note: event.note,
97 since: me.state === state ? me.since : at,
98 updatedAt: at,
99 events: [...me.events, event].slice(-MAX_EVENTS),
100 }
101 await $.fs.write(`${await boardDir($)}/${me.id}.json`, JSON.stringify(me, null, 1))
102 await refresh($)
103}
104
105// Writes queue one behind another so a session's file never interleaves.
106function record($: EngineInterface, state: BoardState, note: string) {
107 writes = writes.then(() => write($, state, note)).catch(() => undefined)
108 return writes
109}
110
111// The desktop app keeps a file per session holding its sidebar title, keyed by
112// the same id the engine gives this session. Its format is the app's own and
113// undocumented, so any miss here just leaves the board on project + prompt.
114let desktopFile = ''
115
116async function findDesktopFile($: EngineInterface, id: string) {
117 const local = await $.env.get('LOCALAPPDATA')
118 if (!local) return ''
119 const root = `${local}/Claude-Data`
120 if (!(await $.fs.exists(root))) return ''
121 const files: { path: string; mtimeMs: number }[] = []
122 for (const app of await $.fs.list(root)) {
123 if (app.kind !== 'dir' || !app.name.startsWith('claude_')) continue
124 const base = `${root}/${app.name}/claude-code-sessions`
125 if (!(await $.fs.exists(base))) continue
126 for (const a of await $.fs.list(base)) {
127 if (a.kind !== 'dir') continue
128 for (const b of await $.fs.list(`${base}/${a.name}`)) {
129 if (b.kind !== 'dir') continue
130 for (const f of await $.fs.list(`${base}/${a.name}/${b.name}`)) {
131 if (f.kind === 'file' && f.name.startsWith('local_') && f.name.endsWith('.json')) {
132 files.push({ path: `${base}/${a.name}/${b.name}/${f.name}`, mtimeMs: f.mtimeMs })
133 }
134 }
135 }
136 }
137 }
138 // The live session's file was touched last, so newest first usually finds it at once.
139 for (const file of files.sort((x, y) => y.mtimeMs - x.mtimeMs)) {
140 const text = await $.fs.read(file.path).catch(() => '')
141 if (!text.includes(id)) continue
142 try {
143 if ((JSON.parse(text) as { cliSessionId?: string }).cliSessionId === id) return file.path
144 } catch {
145 // Mid-write: try the next.
146 }
147 }
148 return ''
149}
150
151async function syncTitle($: EngineInterface) {
152 if (!me) return
153 if (!desktopFile) desktopFile = await findDesktopFile($, me.id)
154 if (!desktopFile) return
155 const text = await $.fs.read(desktopFile).catch(() => '')
156 let title: string | undefined
157 try {
158 title = (JSON.parse(text) as { title?: string }).title
159 } catch {
160 return
161 }
162 if (!title || title === me.title) return
163 me = { ...me, title }
164 writes = writes
165 .then(async () => {
166 if (!me) return
167 await $.fs.write(`${await boardDir($)}/${me.id}.json`, JSON.stringify(me, null, 1))
168 await refresh($)
169 })
170 .catch(() => undefined)
171}
172
173const nameOf = (s: BoardSession) => s.title || s.project
174
175export const register: Register = on => {
176 on('session.start', async ($, e, next) => {
177 const id = await $.session.id()
178 const folder = await boardDir($)
179 const path = `${folder}/${id}.json`
180 const now = await $.clock.now()
181 const saved = (await $.fs.exists(path))
182 ? (JSON.parse(await $.fs.read(path)) as BoardSession)
183 : undefined
184 me = saved ?? {
185 id,
186 project: lastPart(e.cwd),
187 cwd: e.cwd,
188 label: '',
189 state: 'your-turn',
190 since: now,
191 note: 'Started',
192 updatedAt: now,
193 events: [],
194 }
195 await update($, selfId, () => id)
196
197 await $.command.register({ name: 'board', description: 'Show every Claude Code session: which need you, finished, working' })
198 await $.command.register({ name: 'board-log', description: "Today's timeline across every Claude Code session" })
199
200 await record($, saved ? saved.state : 'your-turn', saved ? 'Resumed' : 'Started')
201 $.clock.every(REFRESH_MS, () => refresh($))
202 void syncTitle($)
203 $.clock.every(TITLE_MS, () => syncTitle($))
204
205 return next(e)
206 })
207
208 on('prompt.submit', async ($, e, next) => {
209 if (me && !me.label && !e.isMeta) me = { ...me, label: oneLine(e.text, 70) }
210 if (!e.isMeta) await record($, 'working', e.text)
211 return next(e)
212 })
213
214 on('classic.PermissionRequest', async ($, e, next) => {
215 await record($, 'needs-you', `Approve ${e.tool_name}?`)
216 return next(e)
217 })
218
219 on('classic.Notification', async ($, e, next) => {
220 // The idle reminder repeats "your turn"; everything else is a real ask.
221 if (e.notification_type !== 'idle_prompt' && me?.state !== 'your-turn') {
222 await record($, 'needs-you', e.message)
223 }
224 return next(e)
225 })
226
227 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
228 await record($, 'needs-you', `Question: ${e.questions[0]?.question ?? ''}`)
229 const result = await next(e)
230 await record($, 'working', 'Answered')
231 return result
232 })
233
234 on('tool.call', async ($, e, next) => {
235 // An approved permission means the session is moving again.
236 if (me?.state === 'needs-you') await record($, 'working', `Running ${e.tool}`)
237 return next(e)
238 })
239
240 on('turn.complete', async ($, e, next) => {
241 if (e.agentId === undefined) {
242 await record($, 'your-turn', e.isAborted ? 'Stopped' : e.answer || 'Finished')
243 }
244 return next(e)
245 })
246
247 on('session.end', async ($, e, next) => {
248 await record($, 'ended', `Ended (${e.reason})`)
249 return next(e)
250 })
251
252 on('command.run', { command: 'board' }, async $ => {
253 await refresh($)
254 await $.ui.open(PANE_ARGS)
255 return { text: 'Sessions board opened.' }
256 })
257
258 on('command.run', { command: 'board-log' }, async $ => {
259 const all = await loadAll($)
260 const now = new Date(await $.clock.now())
261 const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
262 const rows = all
263 .flatMap(s => s.events.filter(ev => ev.at >= midnight).map(ev => ({ ...ev, who: s.title || `${s.project} · ${s.label || s.id.slice(0, 8)}` })))
264 .sort((a, b) => a.at - b.at)
265 if (rows.length === 0) return { text: 'No session activity today yet.' }
266 const lines = rows.map(r => `${time(r.at)} ${HEADINGS[r.state].padEnd(19)} ${r.who} — ${r.note}`)
267 return { text: `Today across ${new Set(rows.map(r => r.who)).size} sessions:\n\n${lines.join('\n')}` }
268 })
269
270 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
271 const { Box, Text } = $.ui.resolve(e)
272 const list = await read($, sessions)
273 const mine = await read($, selfId)
274 const now = await $.clock.now()
275 const width = Math.max(20, e.props.bodyColumns)
276
277 const shown = list.filter(s => s.state !== 'ended' || now - s.updatedAt < SHOW_ENDED_MS)
278 const waiting = shown.filter(s => s.state === 'needs-you').length
279
280 return (
281 <Box flexDirection="column">
282 <Text bold color={waiting ? 'warning' : 'success'}>
283 {waiting ? `${waiting} need${waiting === 1 ? 's' : ''} you` : 'Nothing waiting on you'}
284 <Text dimColor> · {shown.filter(s => s.state !== 'ended').length} open</Text>
285 </Text>
286 {shown.length === 0 && <Text dimColor>No sessions recorded yet.</Text>}
287 {ORDER.map(state => {
288 const group = shown
289 .filter(s => s.state === state)
290 .sort((a, b) => (state === 'ended' ? b.updatedAt - a.updatedAt : a.since - b.since))
291 if (group.length === 0) return null
292 return (
293 <Box flexDirection="column" marginTop={1}>
294 <Text color={COLORS[state]} bold>
295 {HEADINGS[state]} ({group.length})
296 </Text>
297 {group.map(s => {
298 const isQuiet = state === 'working' && now - s.updatedAt > QUIET_MS
299 return (
300 <Box flexDirection="column">
301 <Text dimColor={state === 'ended'}>
302 {s.id === mine ? '▸ ' : ' '}
303 <Text bold>{oneLine(nameOf(s), Math.max(10, width - 12))}</Text>
304 {!s.title && s.label ? ` · ${oneLine(s.label, Math.max(10, width - s.project.length - 14))}` : ''}
305 <Text dimColor> {ago(now - s.since)}{isQuiet ? ' (quiet)' : ''}</Text>
306 </Text>
307 <Text dimColor> {oneLine(s.note, Math.max(10, width - 4))}</Text>
308 </Box>
309 )
310 })}
311 </Box>
312 )
313 })}
314 </Box>
315 )
316 })
317}
318types/index.d.ts 24 lines1export type BoardState = 'needs-you' | 'your-turn' | 'working' | 'ended'
2
3export type BoardEvent = { at: number; state: BoardState; note: string }
4
5export type BoardSession = {
6 id: string
7 project: string
8 cwd: string
9 label: string
10 /** The desktop app's sidebar title, when the session runs there. */
11 title?: string
12 state: BoardState
13 since: number
14 note: string
15 updatedAt: number
16 events: BoardEvent[]
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'session-board': { sessions: BoardSession[]; selfId: string }
22 }
23}
24