SLOPSHOPPER

sessions

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.

newpanecommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sessions
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running · 0 working [ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No running sessions found. ⎿ 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 │ │ › /sessions │ ⎿ sessions: Sessions pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
0 running · 0 working [ Refresh ] No running sessions found.
README

claude-code-mods

Small mods for Claude Code, built on its function-hooks plugin API.

ModWhat it does
turn-pulseA 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.

Install

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.

turn-pulse

turn-pulse: the last-turn band above the prompt and the session total in the status line

A band labeled ◆ last turn sits above the prompt after each turn:

  • Duration of the turn, in yellow past 2 minutes.
  • Files edited, in green. An edit outside the session's working folder shows in bold red, with a 15 second toast.
  • Tool results: ✓ N ok, and ✗ N failed (Tool ×n) when something failed.
  • Usage-limit cost of the turn, for example 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:

  • Usage numbers are account-wide, so other sessions running at the same time count toward them.
  • Tracking starts when the mod loads, and the handling of a limit window resetting is heuristic.
  • Paths are compared case-insensitively. That is right on Windows but could misjudge an "outside" edit on a case-sensitive Linux file system.

notes

notes: the Notes side pane with an open note

  • /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 are kept in the mod's own store, shared across all sessions and projects.
  • /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.
  • Text pasted into the prompt has its <pasted_content> wrapper stripped.

Limits:

  • In the desktop app the selection is not passed to mods, so /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.
  • Jump only scrolls within the session the note was taken in; mods can't open another session. Tracked in #4.

sessions

/sessions opens a Sessions side pane listing every Claude Code session running on this machine, from any folder:

  • A count of running and working sessions, and a Refresh button.
  • One row per session: ● working (yellow) or ○ idle (green), the session's name, how long since its last activity, its folder, and "this session" on your own row.
  • Working sessions sort first, then the most recently active.
  • The pane refreshes every 5 seconds while it is open and stops when you close it.

Limits:

  • The list comes from Claude Code's own files in ~/.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.
  • Only sessions running on this machine appear. Closed sessions and cloud sessions do not.
  • The pane can't switch to another session; it only shows them. Tracked in #4.
  • Sessions and Notes open as tabs in the same side pane; mod panes can't be shown side by side. Tracked in #5.
  • A session that crashed can leave its file behind. A "working" row with no activity for 30 minutes shows dimmed as "working?".

Developing

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.

License

MIT

Source 3 files
hooks/register.tsx 133 lines
1import { 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}
133
hooks/parse.ts 68 lines
1import 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}
68
types/index.d.ts 18 lines
1// 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