SLOPSHOPPER

disk-sweep

Warns when your Mac is low on disk space and, with /disk, lists old Claude Code leftovers you can tick and delete.

newbandcommandtoaststatusprocess
v0.1.0MITupdated 2026-10-13varunmoka7/disk-sweep
A shopper browsing a rack in a slop shop
README

disk-sweep

A Claude Code mod that warns you when your Mac is running low on disk space and, when you type /disk, lists old Claude Code leftovers you can tick and delete.

Why you might want it

Claude Code keeps old plugin versions, old conversation transcripts, file backups and debug logs in its config folder, and they pile up over months. Example: your Mac drops under 30 GB free, a note appears at the bottom of Claude Code, you type /disk, tick "Tick all safe", press the remove button twice, and get a few gigabytes back.

Install

Paste these two lines into a terminal (not into Claude):

claude plugin marketplace add varunmoka7/disk-sweep
claude plugin install disk-sweep@disk-sweep

Needs Claude Code 2.1.287 or newer (mods are on by default from that version).

Mac only. On Linux and Windows the mod does nothing and shows no errors.

How to use

  • Below 30 GB free, a line like 25.0G free · /disk shows in the status area, plus one pop-up note per day.
  • /disk scans and lists what could be removed. /disk --days 30 changes how old a transcript must be to be listed (the default is 14 days).
  • A panel appears above the prompt. Press Tick on the rows you want, or Tick all safe.
  • Remove ticked asks you to press again (Confirm ...?). The second press rescans, then deletes only what is still there.
  • Dismiss closes the panel.

What it can list

KindWhatRule
safeOld plugin versions in plugins/cache/Not the installed version, a newer version is installed, and it is 1 day past Claude's own "orphaned" mark or 7 days untouched
safeLeftover plugin download folders (temp_git_*, temp_subdir_*)Older than 1 day
safefile-history/ filesOlder than 30 days
safedebug/ filesOlder than 7 days
lookConversation transcripts in projects/Older than the cutoff, one row per project. Deleted transcripts can no longer be resumed.

Anything else in your Claude folder (memory, plans, skills, settings, marketplaces) is never listed.

What it touches

  • Network: none.
  • Files read: your Claude config folder (CLAUDE_CONFIG_DIR if you set it, otherwise ~/.claude) and plugins/installed_plugins.json inside it.
  • Files written: none, except the files you tick and confirm for removal. Those are deleted with rm -rf and cannot be undone.
  • Programs it runs: df, find, stat, du, uname, test, rm.
  • Model calls: none. No cost.
  • On by default once installed. It never deletes anything without two presses from you.

Turn it off / uninstall

claude plugin uninstall disk-sweep@disk-sweep

Known limits

  • macOS only.
  • Only the one config folder is scanned. If you use several, set CLAUDE_CONFIG_DIR before starting Claude Code to pick which.
  • CLAUDE_CONFIG_DIR must be a folder inside your home folder; otherwise ~/.claude is used.
  • The 30 GB warning level is fixed.

License

MIT

Source 4 files
hooks/register.tsx 157 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Candidate, Io, Scan } from '../types'
5import { THRESHOLD, configDirs, fmt, freeBytes, isMac, order, parseDays, rm, scan, table } from './scan'
6
7export const state = atom({ plugin: 'disk-sweep', key: 'scan' } as const, null as Scan | null)
8export const confirm = atom({ plugin: 'disk-sweep', key: 'confirm' } as const, false)
9
10let dirs: string[] = []
11// True while a removal runs; every band button is ignored until it ends.
12let busy = false
13
14// $ never crosses a file boundary in a mod, so the scanner gets this closure instead.
15export const io = async ($: EngineInterface): Promise<Io> => ({
16  // Cut-off output can hold a half-written path, so it counts as a failed command.
17  run: (argv, timeoutMs = 120_000) =>
18    $.process.run(argv, { timeoutMs }).then(r => (r.isStdoutTruncated ? { exitCode: 1, stdout: '', stderr: 'output too large' } : r)).catch(() => null),
19  read: path => $.fs.read(path).then(t => t as string).catch(() => null),
20  now: await $.clock.now(),
21})
22
23export async function setScan($: EngineInterface, s: Scan | null) {
24  await update($, state, () => s)
25  await $.store.set('scan', s)
26}
27
28async function patch($: EngineInterface, fn: (c: Candidate) => Candidate) {
29  const s = await read($, state)
30  if (s) await setScan($, { ...s, candidates: s.candidates.map(fn) })
31}
32
33// Any press other than Confirm drops a pending confirmation.
34export async function toggle($: EngineInterface, id: string) {
35  if (busy) return
36  await update($, confirm, () => false)
37  await patch($, c => (c.id === id ? { ...c, ticked: !c.ticked } : c))
38}
39
40export async function tickSafe($: EngineInterface) {
41  if (busy) return
42  await update($, confirm, () => false)
43  await patch($, c => (c.tier === 'safe' ? { ...c, ticked: true } : c))
44}
45
46export async function dismiss($: EngineInterface) {
47  if (busy) return
48  await update($, confirm, () => false)
49  await setScan($, null)
50}
51
52// First press arms Confirm; second press rescans, then deletes the ticked rows one by one.
53// Only paths the fresh scan still finds are removed, so a stored scan never deletes against an old measurement.
54export async function remove($: EngineInterface) {
55  if (busy) return
56  busy = true // set before the first await, so two presses in the same tick cannot both proceed
57  try {
58    const s = await read($, state)
59    if (!s || !s.candidates.some(c => c.ticked)) return
60    if (!(await read($, confirm))) {
61      await update($, confirm, () => true)
62      return
63    }
64    await update($, confirm, () => false)
65    const x = await io($)
66    const fresh = await scan(x, dirs, s.days)
67    if (fresh.error) {
68      $.ui.toast(fresh.error)
69      return
70    }
71    // ponytail: a fresh candidate's bytes are split evenly over its paths; keep per-file sizes if partial matches need exact totals.
72    const share = new Map<string, number>()
73    for (const f of fresh.candidates) for (const p of f.paths) share.set(p, f.bytes / f.paths.length)
74    let freed = 0
75    let left: Candidate[] = s.candidates
76    for (const c of s.candidates) {
77      if (!c.ticked) continue
78      const paths = c.paths.filter(p => share.has(p))
79      const err = paths.length ? await rm(x.run, paths) : null
80      if (err) left = left.map(k => (k.id === c.id ? { ...c, ticked: false, reason: `rm failed: ${err}` } : k))
81      else {
82        left = left.filter(k => k.id !== c.id)
83        freed += paths.reduce((n, p) => n + share.get(p)!, 0)
84      }
85      await setScan($, left.length ? { ...s, candidates: left } : null)
86    }
87    if (freed) $.ui.status(`💽 freed ${fmt(freed)}`)
88  } finally {
89    busy = false
90  }
91}
92
93export const register: Register = on => {
94  on('session.start', async ($, e, next) => {
95    try {
96      const x = await io($)
97      if (!(await isMac(x.run))) return next(e)
98      const home = (await $.env.get('HOME')) ?? ''
99      dirs = configDirs(home, await $.env.get('CLAUDE_CONFIG_DIR'))
100      const stored = ((await $.store.get('scan')) ?? null) as Scan | null
101      await update($, state, () => stored)
102      await $.command.register({ name: 'disk', description: 'Find and remove stale Claude Code data', argumentHint: '[--days N]' })
103      const free = await freeBytes(x.run, home)
104      if (free !== null && free < THRESHOLD) {
105        $.ui.status(`💽 ${fmt(free)} free · /disk`)
106        const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
107        if ((await $.store.get('warnedOn')) !== today) {
108          $.ui.toast(`Disk low: ${fmt(free)} free. Run /disk to sweep Claude data.`)
109          await $.store.set('warnedOn', today)
110        }
111      }
112    } catch {
113      // A failed disk check must never stop the session from starting.
114    }
115    return next(e)
116  })
117
118  on('command.run', { command: 'disk' }, async ($, e) => {
119    const days = parseDays(e.args)
120    const r = await scan(await io($), dirs, days)
121    if (r.error) return { text: r.error }
122    await update($, confirm, () => false)
123    await setScan($, r.candidates.length ? { at: new Date(await $.clock.now()).toISOString(), days, candidates: r.candidates } : null)
124    if (!r.candidates.length) return { text: [...r.notes, 'Nothing to sweep.'].join('\n') }
125    return { text: [...r.notes, table(r.candidates, dirs)].join('\n') }
126  })
127
128  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129    const below = await next(e)
130    if (e.props.hasSurvey) return below
131    const s = await read($, state)
132    if (!s) return below
133    const { Box, Button, Text } = $.ui.resolve(e)
134    const ticked = s.candidates.filter(c => c.ticked)
135    const sum = ticked.reduce((n, c) => n + c.bytes, 0)
136    const pending = await read($, confirm)
137    const band = (
138      <Box flexDirection="column">
139        <Text dimColor>disk-sweep · scanned {s.at.slice(0, 16).replace('T', ' ')} · transcripts older than {s.days}d</Text>
140        {order(s.candidates).map(c => (
141          <Box key={c.id}>
142            <Text>{c.ticked ? '☑' : '☐'} {fmt(c.bytes).padStart(7)} {c.tier.padEnd(4)} {c.dir.split('/').pop()} {c.label} </Text>
143            <Text dimColor>{c.reason} </Text>
144            <Button key={`t-${c.id}`} label={c.ticked ? 'Untick' : 'Tick'} onPress={() => toggle($, c.id)} />
145          </Box>
146        ))}
147        <Box>
148          <Button key="all-safe" label="Tick all safe" onPress={() => tickSafe($)} />
149          <Button key="remove" label={pending ? `Confirm ${fmt(sum)}?` : `Remove ticked (${ticked.length}, ${fmt(sum)})`} onPress={() => remove($)} />
150          <Button key="dismiss" label="Dismiss" onPress={() => dismiss($)} />
151        </Box>
152      </Box>
153    )
154    return below ? <Box flexDirection="column">{below}{band}</Box> : band
155  })
156}
157
hooks/scan.ts 93 lines
1import type { Candidate, Io, Run } from '../types'
2import { TARGETS, isShellError } from './targets'
3
4export const GB = 1024 ** 3
5export const THRESHOLD = 30 * GB
6
7// `df -k <path>`: second line, fourth column is available 1K blocks.
8export function parseDf(stdout: string): number | null {
9  const line = stdout.trim().split('\n')[1]
10  if (!line) return null
11  const avail = Number(line.trim().split(/\s+/)[3])
12  return Number.isFinite(avail) ? avail * 1024 : null
13}
14
15export async function freeBytes(run: Run, home: string): Promise<number | null> {
16  const r = await run(['df', '-k', home])
17  return r && r.exitCode === 0 ? parseDf(r.stdout) : null
18}
19
20export function fmt(bytes: number): string {
21  if (bytes >= GB) return `${(bytes / GB).toFixed(1)}G`
22  if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)}M`
23  return `${Math.round(bytes / 1024)}K`
24}
25
26// The session's config dir: CLAUDE_CONFIG_DIR when it is an absolute folder under home, else ~/.claude.
27// rm runs inside it, so home itself and anything with ".." are never trusted.
28export function configDirs(home: string, configDir?: string): string[] {
29  const given = (configDir ?? '').replace(/\/$/, '')
30  const trusted = home !== '' && given.startsWith(`${home}/`) && !given.split('/').includes('..')
31  return [trusted ? given : `${home}/.claude`]
32}
33
34// The find, stat and df flags this mod uses are the macOS ones.
35export async function isMac(run: Run): Promise<boolean> {
36  const r = await run(['uname', '-s'])
37  return r?.exitCode === 0 && r.stdout.trim() === 'Darwin'
38}
39
40export function parseDays(args: string): number {
41  const m = args.match(/--days\s+(\d+)(?=\s|$)/)
42  const n = m ? Number(m[1]) : 0
43  return n >= 1 ? n : 14
44}
45
46export async function scan(io: Io, dirs: string[], days: number): Promise<{ candidates: Candidate[]; notes: string[]; error?: string }> {
47  const candidates: Candidate[] = []
48  const notes: string[] = []
49  try {
50    for (const dir of dirs) {
51      const r = await io.run(['test', '-d', dir])
52      if (!r || r.exitCode !== 0) continue
53      for (const t of TARGETS) {
54        const found = await t.find(io, dir, days)
55        candidates.push(...found.candidates)
56        if (found.note) notes.push(found.note)
57      }
58    }
59  } catch (e) {
60    if (isShellError(e)) return { candidates: [], notes, error: e.message }
61    throw e
62  }
63  return { candidates, notes }
64}
65
66// Safe first, then larger first.
67export const order = (list: Candidate[]) => [...list].sort((a, b) => (a.tier === b.tier ? b.bytes - a.bytes : a.tier === 'safe' ? -1 : 1))
68
69// One row per group; a size column per config dir, shown by its basename.
70export function table(candidates: Candidate[], dirs: string[]): string {
71  const groups = [...new Set(candidates.map(c => c.group))]
72  const names = dirs.map(d => d.split('/').pop()!)
73  const sum = (group: string, dir: string) => candidates.filter(c => c.group === group && c.dir === dir).reduce((n, c) => n + c.bytes, 0)
74  const w0 = Math.max('group'.length, ...groups.map(g => g.length)) + 2
75  const cols = (cells: string[], first: string) => first.padEnd(w0) + cells.map((c, i) => c.padStart(names[i]!.length + 2)).join('') + '  '
76  const head = cols(names, 'group') + 'tier'
77  const rows = groups.map(g => {
78    const tier = candidates.find(c => c.group === g)!.tier
79    return cols(dirs.map(d => (sum(g, d) ? fmt(sum(g, d)) : '0')), g) + tier
80  })
81  return [head, ...rows].join('\n')
82}
83
84// rm -rf in chunks of 200 so a long file list stays under the argv limit. Null on success.
85export async function rm(run: Run, paths: string[]): Promise<string | null> {
86  for (let i = 0; i < paths.length; i += 200) {
87    const r = await run(['rm', '-rf', '--', ...paths.slice(i, i + 200)], 300_000)
88    if (!r) return 'rm did not run'
89    if (r.exitCode !== 0) return (r.stderr ?? '').trim() || `rm exit ${r.exitCode}`
90  }
91  return null
92}
93
hooks/targets.ts 188 lines
1import type { Candidate, Io, Tier } from '../types'
2
3export type Found = { candidates: Candidate[]; note?: string }
4export type Target = { group: string; tier: Tier; find: (io: Io, dir: string, days: number) => Promise<Found> }
5
6// `find ... -exec stat -f '%z %m %N' {} +` lines: bytes, mtime epoch, path (may contain spaces).
7export function parseStat(stdout: string): { bytes: number; mtime: number; path: string }[] {
8  return stdout
9    .split('\n')
10    .filter(Boolean)
11    .map(l => {
12      const [bytes, mtime, ...rest] = l.split(' ')
13      return { bytes: Number(bytes), mtime: Number(mtime), path: rest.join(' ') }
14    })
15    .filter(f => Number.isFinite(f.bytes) && Number.isFinite(f.mtime))
16}
17
18// `stat -f '%m %N'` lines: mtime epoch, path.
19export function parseMtime(stdout: string): { mtime: number; path: string }[] {
20  return stdout
21    .split('\n')
22    .filter(Boolean)
23    .map(l => {
24      const [mtime, ...rest] = l.split(' ')
25      return { mtime: Number(mtime), path: rest.join(' ') }
26    })
27    .filter(d => Number.isFinite(d.mtime))
28}
29
30// `du -sk a b` lines: kilobytes, tab, path.
31export function parseDu(stdout: string): Map<string, number> {
32  const m = new Map<string, number>()
33  for (const l of stdout.split('\n')) {
34    const i = l.indexOf('\t')
35    const k = l.slice(0, i)
36    const p = l.slice(i + 1)
37    if (i > 0 && p && Number.isFinite(Number(k))) m.set(p, Number(k) * 1024)
38  }
39  return m
40}
41
42// installed_plugins.json: { plugins: { "<name>@<mp>": [{ installPath }] } }
43export function installPaths(json: unknown): Set<string> {
44  const plugins = (json as { plugins?: Record<string, { installPath?: string }[]> } | null)?.plugins ?? {}
45  return new Set(
46    Object.values(plugins)
47      .flat()
48      .map(e => e?.installPath?.replace(/\/$/, ''))
49      .filter((p): p is string => !!p),
50  )
51}
52
53// A version dir is stale when it is not installed but a sibling of the same plugin is.
54// Age is checked separately in staleVersions, since an old session may still run it.
55export function staleDirs(dirs: string[], installed: Set<string>): string[] {
56  const parent = (p: string) => p.slice(0, p.lastIndexOf('/'))
57  const livePlugins = new Set([...installed].map(parent))
58  return dirs.filter(d => !installed.has(d) && livePlugins.has(parent(d)))
59}
60
61// projects/<folder> is the cwd with slashes turned to dashes; drop the home part.
62export const projectLabel = (folder: string) => folder.replace(/^-Users-[^-]+-?/, '') || '~'
63
64class ShellError extends Error {}
65
66// A non-zero exit is a scan failure the command reports verbatim.
67async function sh(io: Io, argv: string[]): Promise<string> {
68  const r = await io.run(argv)
69  if (!r) throw new ShellError(`${argv[0]} did not run`)
70  if (r.exitCode !== 0) throw new ShellError(`${argv.join(' ')}: ${(r.stderr ?? '').trim() || `exit ${r.exitCode}`}`)
71  return r.stdout
72}
73
74export const isShellError = (e: unknown): e is ShellError => e instanceof ShellError
75
76const exists = async (io: Io, path: string) => ((await io.run(['test', '-d', path]))?.exitCode ?? 1) === 0
77
78const lines = (s: string) => s.split('\n').filter(Boolean)
79
80// Names with a newline would split into forged lines in find's output, so never list them.
81const NO_NL = ['-not', '-path', '*\n*']
82
83const STAT = [...NO_NL, '-exec', 'stat', '-f', '%z %m %N', '{}', '+']
84
85const DAY = 86_400
86
87// The only places a candidate path may live, relative to its config dir.
88const SCOPE = ['plugins/cache/', 'file-history/', 'debug/', 'projects/']
89
90const date = (epoch: number) => new Date(epoch * 1000).toISOString().slice(0, 10)
91
92// Every target goes through here: a path outside SCOPE fails the whole scan, so nothing is listed.
93function candidate(dir: string, group: string, tier: Tier, label: string, reason: string, paths: string[], bytes: number): Candidate {
94  if (!paths.length) throw new ShellError(`${group}: candidate with no paths`)
95  for (const p of paths) {
96    const rel = p.startsWith(`${dir}/`) ? p.slice(dir.length + 1) : ''
97    const inScope = p.startsWith('/') && SCOPE.some(s => rel.startsWith(s) && rel.length > s.length)
98    if (!inScope || p.split('/').includes('..') || p.includes('\n')) throw new ShellError(`refusing path outside the sweep scope: ${p}`)
99  }
100  if (!Number.isFinite(bytes) || bytes < 0) throw new ShellError(`${paths[0]}: bad size ${bytes}`)
101  return { id: `${dir}:${group}:${paths[0]}`, dir, group, tier, label, reason, paths, bytes, ticked: false }
102}
103
104type Stat = ReturnType<typeof parseStat>[number]
105
106function fileGroup(dir: string, group: string, tier: Tier, label: string, days: number, files: Stat[]): Candidate {
107  let min = Infinity
108  for (const f of files) if (f.mtime < min) min = f.mtime
109  const oldest = date(min)
110  return candidate(dir, group, tier, label, `${files.length} ${files.length === 1 ? 'file' : 'files'} older than ${days}d, oldest ${oldest}`, files.map(f => f.path), files.reduce((n, f) => n + f.bytes, 0))
111}
112
113// Files under <dir>/<sub> older than `days`, as one candidate.
114function agedFiles(sub: string, days: number, tier: Tier): Target {
115  return {
116    group: sub,
117    tier,
118    async find(io, dir) {
119      const root = `${dir}/${sub}`
120      if (!(await exists(io, root))) return { candidates: [] }
121      const files = parseStat(await sh(io, ['find', root, '-type', 'f', '-mtime', `+${days}`, ...STAT]))
122      return { candidates: files.length ? [fileGroup(dir, sub, tier, sub, days, files)] : [] }
123    },
124  }
125}
126
127const staleVersions: Target = {
128  group: 'stale plugin versions',
129  tier: 'safe',
130  async find(io, dir) {
131    const cache = `${dir}/plugins/cache`
132    if (!(await exists(io, cache))) return { candidates: [] }
133    let installed: Set<string>
134    try {
135      installed = installPaths(JSON.parse((await io.read(`${dir}/plugins/installed_plugins.json`)) ?? ''))
136    } catch {
137      return { candidates: [], note: `${dir}: installed_plugins.json unreadable, stale versions skipped` }
138    }
139    const listed = parseMtime(await sh(io, ['find', cache, '-mindepth', '3', '-maxdepth', '3', '-type', 'd', '-not', '-path', '*/temp_*', ...NO_NL, '-exec', 'stat', '-f', '%m %N', '{}', '+']))
140    const marked = new Set(lines(await sh(io, ['find', cache, '-mindepth', '4', '-maxdepth', '4', '-name', '.orphaned_at', ...NO_NL])).map(p => p.slice(0, p.lastIndexOf('/'))))
141    const secs = new Map(listed.map(d => [d.path, io.now / 1000 - d.mtime]))
142    // A session started before an update may still run the old version: wait 1d after Claude orphans it, 7d otherwise.
143    const stale = staleDirs([...secs.keys()], installed).filter(d => secs.get(d)! > (marked.has(d) ? 1 : 7) * DAY)
144    if (!stale.length) return { candidates: [] }
145    const sizes = parseDu(await sh(io, ['du', '-sk', ...stale]))
146    const current = (plugin: string) => [...installed].find(i => i.startsWith(`${plugin}/`))?.split('/').pop()
147    return {
148      candidates: stale.map(p => {
149        const plugin = p.slice(0, p.lastIndexOf('/'))
150        const [name, ver] = p.split('/').slice(-2)
151        return candidate(dir, 'stale plugin versions', 'safe', `${name} ${ver}`, `not installed, ${current(plugin)} is; ${marked.has(p) ? 'orphaned' : 'untouched'} ${Math.floor(secs.get(p)! / DAY)}d${marked.has(p) ? ' ago' : ''}`, [p], sizes.get(p) ?? 0)
152      }),
153    }
154  },
155}
156
157const tempClones: Target = {
158  group: 'plugin cache temp clones',
159  tier: 'safe',
160  async find(io, dir) {
161    const cache = `${dir}/plugins/cache`
162    if (!(await exists(io, cache))) return { candidates: [] }
163    const dirs = lines(await sh(io, ['find', cache, '-mindepth', '1', '-maxdepth', '1', '(', '-name', 'temp_git_*', '-o', '-name', 'temp_subdir_*', ')', '-mtime', '+0', ...NO_NL]))
164    if (!dirs.length) return { candidates: [] }
165    const sizes = parseDu(await sh(io, ['du', '-sk', ...dirs]))
166    const bytes = dirs.reduce((n, d) => n + (sizes.get(d) ?? 0), 0)
167    return { candidates: [candidate(dir, 'plugin cache temp clones', 'safe', `${dirs.length} temp clones`, 'temp_git_* / temp_subdir_* older than 1d', dirs, bytes)] }
168  },
169}
170
171const transcripts: Target = {
172  group: 'old transcripts',
173  tier: 'look',
174  async find(io, dir, days) {
175    const root = `${dir}/projects`
176    if (!(await exists(io, root))) return { candidates: [] }
177    const files = parseStat(await sh(io, ['find', root, '-name', '*.jsonl', '-type', 'f', '-mtime', `+${days}`, ...STAT]))
178    const byProject = new Map<string, Stat[]>()
179    for (const f of files) {
180      const p = f.path.slice(root.length + 1).split('/')[0]!
181      byProject.set(p, [...(byProject.get(p) ?? []), f])
182    }
183    return { candidates: [...byProject].map(([p, fs]) => fileGroup(dir, 'old transcripts', 'look', projectLabel(p), days, fs)) }
184  },
185}
186
187export const TARGETS: Target[] = [staleVersions, tempClones, agedFiles('file-history', 30, 'safe'), agedFiles('debug', 7, 'safe'), transcripts]
188
types/index.d.ts 27 lines
1export type Tier = 'safe' | 'look'
2
3// One removable thing: a dir, or a list of files that share a rule.
4export type Candidate = {
5  id: string      // `${dir}:${group}:${paths[0]}`, stable across rescans
6  dir: string     // the Claude config dir it was found in
7  group: string   // the target it came from, used as the table row
8  tier: Tier
9  label: string   // e.g. "some-plugin 1.2.0", "code-my-app"
10  reason: string  // e.g. "not installed, 13.34.2 is", "412 files older than 14d, oldest 2026-08-14"
11  paths: string[] // what rm removes
12  bytes: number
13  ticked: boolean
14}
15
16export type Scan = { at: string; days: number; candidates: Candidate[] }
17
18export type Reply = { exitCode: number; stdout: string; stderr?: string } | null
19export type Run = (argv: string[], timeoutMs?: number) => Promise<Reply>
20export type Io = { run: Run; read: (path: string) => Promise<string | null>; now: number } // now: epoch ms
21
22declare module 'claude-code' {
23  interface PluginState {
24    'disk-sweep': { scan: Scan | null; confirm: boolean }
25  }
26}
27