SLOPSHOPPER

session-board

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

newpaneguardcommandprompttimer
v0.3.0MITupdated 2026-10-25iiljohne/claude-session-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-board
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing waiting on you · 0 open │ ┃ No sessions recorded yet. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ session-board: Sessions board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
Nothing waiting on you · 0 open No sessions recorded yet.
README

session-board

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:

  • Needs you — a permission prompt, a question, or another ask
  • Finished, your turn — the turn ended; the last line of the answer is shown
  • Working — running; marked (quiet) after two hours with no activity
  • Ended today — closed within the last 12 hours

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.

Commands

  • /board — open the board pane
  • /board-log — today's timeline across every session, oldest first

How it works

Every 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.

Install

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.

Source 2 files
hooks/register.tsx 318 lines
1import { 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}
318
types/index.d.ts 24 lines
1export 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