/sessions reports Claude Code session files per project; /sessions archive <days> moves old ones to a gzipped archive.

A Claude Code mod that adds /sessions.
/sessions lists each project folder in the Claude Code projects dir with its number of *.jsonl session files, their total size, and the oldest and newest modified dates (UTC). Biggest first, with a total. It only reports. If cleanupPeriodDays is not set in ~/.claude/settings.json, it prints a hint, since Claude Code deletes sessions after 30 days by default./sessions archive <days> moves session files older than <days> days to <projectsDir>/../session-archive/<project>/ and gzips them (mkdir -p, mv -n, gzip). It reports the count and the bytes freed. It never archives the current session's file and never overwrites an archived file.Nothing is ever deleted. Needs mv and gzip.
Option projectsDir (default ~/.claude/projects): the projects folder. A leading ~ means your home dir.
/plugin install sessions --marketplace AliakbarNateghi/cc-sessionshooks/register.ts 103 lines1import type { Register } from 'claude-code'
2
3const DAY = 86_400_000
4
5const fmtBytes = (n: number) => {
6 const units = ['B', 'KB', 'MB', 'GB', 'TB']
7 let i = 0
8 while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
9 return `${i === 0 ? n : n.toFixed(1)} ${units[i]}`
10}
11const fmtDate = (ms: number) => new Date(ms).toISOString().slice(0, 10)
12
13export const register: Register = (on, options) => {
14 on('session.start', async ($, e, next) => {
15 await $.command.register({
16 name: 'sessions',
17 description: 'Report session files per project. "archive <days>" moves older ones to a gzipped archive.',
18 argumentHint: '[archive <days>]',
19 })
20 return next(e)
21 })
22
23 on('command.run', { command: 'sessions' }, async ($, e) => {
24 const args = e.args.trim().split(/\s+/).filter(Boolean)
25 const isArchive = args[0] === 'archive'
26 const days = Number(args[1])
27 if (args.length > 0 && !(isArchive && args.length === 2 && Number.isFinite(days) && days >= 0)) {
28 return { text: 'Usage: /sessions or /sessions archive <days>' }
29 }
30
31 let dir = String(options.projectsDir || '~/.claude/projects').replace(/\/+$/, '')
32 if (dir === '~' || dir.startsWith('~/')) {
33 const home = await $.env.get('HOME')
34 if (!home) return { text: 'sessions: HOME is not set, cannot resolve ~.' }
35 dir = home + dir.slice(1)
36 }
37 if (!(await $.fs.exists(dir))) return { text: `sessions: ${dir} does not exist.` }
38
39 const projects = await Promise.all(
40 (await $.fs.list(dir)).filter(d => d.kind === 'dir').map(async d => ({
41 name: d.name,
42 files: (await $.fs.list(`${dir}/${d.name}`)).filter(f => f.kind === 'file' && f.name.endsWith('.jsonl')),
43 })),
44 )
45
46 if (isArchive) {
47 const current = `${await $.session.id()}.jsonl`
48 const cutoff = (await $.clock.now()) - days * DAY
49 const archiveRoot = `${dir.replace(/\/[^/]*$/, '') || '/'}/session-archive`
50 const run = (argv: string[]) =>
51 $.process.run(argv, { timeoutMs: 120_000 }).catch(() => ({ exitCode: 1, stdout: '', stderr: 'could not start' }))
52 let count = 0, freed = 0
53 const errors: string[] = []
54 for (const p of projects) {
55 // Never touch the running session's own transcript.
56 const old = p.files.filter(f => f.mtimeMs < cutoff && f.name !== current)
57 if (old.length === 0) continue
58 const dest = `${archiveRoot}/${p.name}`
59 if ((await run(['mkdir', '-p', dest])).exitCode !== 0) { errors.push(`mkdir failed: ${dest}`); continue }
60 for (const f of old) {
61 const src = `${dir}/${p.name}/${f.name}`
62 // -n: never overwrite an archived file; if the target exists the source stays put.
63 await run(['mv', '-n', src, `${dest}/`])
64 if (await $.fs.exists(src)) { errors.push(`not moved: ${src}`); continue }
65 count++
66 freed += f.size
67 const gz = await run(['gzip', `${dest}/${f.name}`])
68 if (gz.exitCode !== 0) errors.push(`moved but not gzipped: ${dest}/${f.name}: ${gz.stderr.trim()}`)
69 }
70 }
71 const text = `Archived ${count} session file(s) older than ${days} day(s) to ${archiveRoot}, freed ${fmtBytes(freed)} (${freed} bytes) from ${dir}.`
72 return { text: errors.length ? `${text}\n${errors.join('\n')}` : text }
73 }
74
75 const rows = projects.map(p => {
76 const times = p.files.map(f => f.mtimeMs)
77 return {
78 name: p.name,
79 count: p.files.length,
80 size: p.files.reduce((s, f) => s + f.size, 0),
81 oldest: times.length ? fmtDate(Math.min(...times)) : '-',
82 newest: times.length ? fmtDate(Math.max(...times)) : '-',
83 }
84 }).sort((a, b) => b.size - a.size)
85
86 const nw = Math.max(7, ...rows.map(r => r.name.length))
87 const line = (n: string, c: string, s: string, o: string, w: string) =>
88 `${n.padEnd(nw)} ${c.padStart(5)} ${s.padStart(9)} ${o.padEnd(10)} ${w}`
89 const total = rows.reduce((t, r) => ({ count: t.count + r.count, size: t.size + r.size }), { count: 0, size: 0 })
90 const out = [
91 `Sessions in ${dir}:`,
92 line('project', 'files', 'size', 'oldest', 'newest'),
93 ...rows.map(r => line(r.name, String(r.count), fmtBytes(r.size), r.oldest, r.newest)),
94 line('TOTAL', String(total.count), fmtBytes(total.size), '', ''),
95 ]
96 const user = await $.settings.read({ source: 'user' }).catch(() => ({} as Record<string, unknown>))
97 if ((user as Record<string, unknown>).cleanupPeriodDays === undefined) {
98 out.push('', 'Hint: Claude Code deletes sessions after 30 days; add "cleanupPeriodDays": 99999 to keep them.')
99 }
100 return { text: out.join('\n') }
101 })
102}
103