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

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.
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.
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.
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).Confirm ...?). The second press rescans, then deletes only what is still there.| Kind | What | Rule |
|---|---|---|
| safe | Old 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 |
| safe | Leftover plugin download folders (temp_git_*, temp_subdir_*) | Older than 1 day |
| safe | file-history/ files | Older than 30 days |
| safe | debug/ files | Older than 7 days |
| look | Conversation 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.
CLAUDE_CONFIG_DIR if you set it, otherwise ~/.claude) and plugins/installed_plugins.json inside it.rm -rf and cannot be undone.df, find, stat, du, uname, test, rm.claude plugin uninstall disk-sweep@disk-sweep
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.MIT
hooks/register.tsx 157 lines1import { 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}
157hooks/scan.ts 93 lines1import 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}
93hooks/targets.ts 188 lines1import 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]
188types/index.d.ts 27 lines1export 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