SLOPSHOPPER

deriveddata-janitor

Finds Xcode DerivedData folders whose workspace is gone (removed worktrees), shows them in a status line and cleans them on an explicit command

newguardcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deriveddata-janitor
› fix the failing auth test and add an audit log call ⏺ 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 › /deriveddata-janitor ⎿ deriveddata-janitor: No orphan DerivedData folders. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

deriveddata-janitor

Finds Xcode DerivedData folders whose workspace no longer exists (removed git worktrees...) and deletes them on explicit request.

What it does

  • Scans ~/Library/Developer/Xcode/DerivedData at session start: for each folder it reads WorkspacePath from info.plist (via plutil) and flags it as an orphan when that path is gone. Sizes come from du -sk.
  • Status line: DerivedData: 3 orphans · 2.4 GB (hidden when there are none).
  • After a successful git worktree remove run by the main agent, rescans and shows a toast for newly orphaned folders.
  • Never deletes anything on its own: only /deriveddata-janitor clean removes folders.

Commands

CommandEffect
/deriveddata-janitor or statusRescan and list orphans, largest first, with their former workspace
/deriveddata-janitor cleanRescan, re-check each folder, delete the orphans, report freed space and skipped folders

Install

claude --plugin-dir /path/to/ModsTools/mods/deriveddata-janitor

Limits

  • Workspaces under /Volumes/ and relative workspace paths are never judged (the disk may just be unmounted).
  • Xcode's shared caches (*.noindex) and hidden entries are never touched; clean only removes a direct child of the DerivedData folder and re-verifies it is still orphaned.
  • macOS only (needs plutil, du); requires HOME to be set.
  • The toast only follows git worktree remove commands run by the main agent.

Develop

claude plugin validate mods/deriveddata-janitor
claude plugin test mods/deriveddata-janitor   # 13 tests
Source 2 files
hooks/register.ts 133 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { DERIVED_SUBPATH, isCacheName, isJudgeable, isSafeTarget, parseDuKb, parseWorkspacePath, report, statusLine, totalKb, formatKb } from './janitor'
4import type { Orphan } from './janitor'
5
6const QUICK_MS = 10_000
7const DU_MS = 20_000
8const RM_MS = 120_000
9const WORKTREE_REMOVE = /\bgit\s+(?:-C\s+\S+\s+)?worktree\s+remove\b/
10
11// Orphans seen by the last scan, to tell new ones after a `git worktree remove`.
12let known = new Set<string>()
13
14async function derivedRoot($: EngineInterface): Promise<string | null> {
15  const home = await $.env.get('HOME')
16
17  return home === undefined || !home.startsWith('/') ? null : `${home.replace(/\/$/, '')}/${DERIVED_SUBPATH}`
18}
19
20async function workspaceOf($: EngineInterface, folder: string): Promise<string | null> {
21  try {
22    const ran = await $.process.run(['plutil', '-extract', 'WorkspacePath', 'raw', '-o', '-', `${folder}/info.plist`], { timeoutMs: QUICK_MS })
23
24    return ran.exitCode === 0 ? parseWorkspacePath(ran.stdout) : null
25  } catch {
26    return null
27  }
28}
29
30async function sizeKb($: EngineInterface, folder: string): Promise<number> {
31  try {
32    const ran = await $.process.run(['du', '-sk', folder], { timeoutMs: DU_MS })
33
34    return ran.exitCode === 0 ? parseDuKb(ran.stdout) : 0
35  } catch {
36    return 0
37  }
38}
39
40// Folders under the root whose info.plist names a workspace that no longer exists.
41async function scan($: EngineInterface, root: string): Promise<Orphan[]> {
42  const entries = await $.fs.list(root).catch(() => [])
43  const found: Orphan[] = []
44  for (const entry of entries) {
45    if (entry.kind !== 'dir' || isCacheName(entry.name)) continue
46    const path = `${root}/${entry.name}`
47    const workspace = await workspaceOf($, path)
48    if (workspace === null || !isJudgeable(workspace) || (await $.fs.exists(workspace).catch(() => true))) continue
49    found.push({ name: entry.name, path, workspace, kb: await sizeKb($, path) })
50  }
51
52  return found
53}
54
55async function refresh($: EngineInterface): Promise<Orphan[]> {
56  const root = await derivedRoot($)
57  const orphans = root === null ? [] : await scan($, root).catch(() => [])
58  known = new Set(orphans.map(o => o.path))
59  $.ui.status(statusLine(orphans))
60
61  return orphans
62}
63
64async function clean($: EngineInterface): Promise<string> {
65  const root = await derivedRoot($)
66  if (root === null) return 'HOME is not set: cannot locate DerivedData.'
67  const fresh = await scan($, root).catch(() => [])
68  let freed = 0
69  let removed = 0
70  const skipped: string[] = []
71  for (const orphan of fresh) {
72    const workspace = await workspaceOf($, orphan.path)
73    const stillGone = workspace !== null && isJudgeable(workspace) && !(await $.fs.exists(workspace).catch(() => true))
74    if (!isSafeTarget(root, orphan.path) || !stillGone) {
75      skipped.push(orphan.name)
76      continue
77    }
78    try {
79      await $.process.run(['rm', '-rf', orphan.path], { timeoutMs: RM_MS })
80    } catch {
81      skipped.push(orphan.name)
82      continue
83    }
84    if (await $.fs.exists(orphan.path).catch(() => true)) skipped.push(orphan.name)
85    else {
86      removed += 1
87      freed += orphan.kb
88    }
89  }
90  await refresh($)
91  const head = removed === 0 ? 'Nothing deleted.' : `Deleted ${removed} orphan folder${removed === 1 ? '' : 's'}, freed ${formatKb(freed)}.`
92
93  return skipped.length === 0 ? head : `${head} Skipped: ${skipped.join(', ')}.`
94}
95
96async function afterWorktreeRemove($: EngineInterface) {
97  const before = known
98  const orphans = await refresh($)
99  const added = orphans.filter(o => !before.has(o.path))
100  if (added.length > 0) $.ui.toast(`DerivedData: ${added.length} new orphan${added.length === 1 ? '' : 's'} (${formatKb(totalKb(added))}) — /deriveddata-janitor clean`)
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    await $.command.register({
106      name: 'deriveddata-janitor',
107      description: 'List (status) or delete (clean) Xcode DerivedData folders whose workspace is gone',
108    })
109    $.clock.after(0, () => void refresh($))
110
111    return next(e)
112  })
113
114  on('command.run', { command: 'deriveddata-janitor' }, async ($, e) => {
115    const arg = e.args.trim()
116    if (arg === 'clean') return { text: await clean($) }
117    if (arg !== '' && arg !== 'status') return { text: 'Usage: /deriveddata-janitor [status|clean]' }
118    const root = await derivedRoot($)
119    if (root === null) return { text: 'HOME is not set: cannot locate DerivedData.' }
120
121    return { text: report(await refresh($)) }
122  })
123
124  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
125    const ran = await next(e)
126    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true && WORKTREE_REMOVE.test(e.command)) {
127      await afterWorktreeRemove($).catch(() => undefined)
128    }
129
130    return ran
131  })
132}
133
hooks/janitor.ts 53 lines
1export type Orphan = { name: string; path: string; workspace: string; kb: number }
2
3export const DERIVED_SUBPATH = 'Library/Developer/Xcode/DerivedData'
4
5// `plutil -extract WorkspacePath raw -o -` prints the bare path; anything else is not one.
6export function parseWorkspacePath(stdout: string): string | null {
7  const path = stdout.trim()
8
9  return path.startsWith('/') && !path.includes('\n') && !path.includes('\0') ? path : null
10}
11
12// Only a path that is gone can be judged: a relative one is unreadable, and a path on /Volumes may just be unmounted.
13export const isJudgeable = (workspace: string) => workspace.startsWith('/') && !workspace.startsWith('/Volumes/')
14
15// A folder name never to touch: Xcode's shared caches (`*.noindex`) and hidden entries.
16export const isCacheName = (name: string) => name.endsWith('.noindex') || name.startsWith('.')
17
18// True only for `<root>/<one plain name>`: never the root, a deeper path, `..`, or a path outside the root.
19export function isSafeTarget(root: string, path: string): boolean {
20  if (root === '' || !root.startsWith('/') || path.includes('\0')) return false
21  const prefix = root.endsWith('/') ? root : `${root}/`
22  if (!path.startsWith(prefix)) return false
23  const name = path.slice(prefix.length)
24
25  return name !== '' && name !== '.' && name !== '..' && !name.includes('/')
26}
27
28export function formatKb(kb: number): string {
29  if (kb >= 1024 * 1024) return `${(kb / (1024 * 1024)).toFixed(1)} GB`
30  if (kb >= 1024) return `${Math.round(kb / 1024)} MB`
31
32  return `${Math.round(kb)} KB`
33}
34
35export const totalKb = (orphans: Orphan[]) => orphans.reduce((sum, orphan) => sum + orphan.kb, 0)
36
37export const statusLine = (orphans: Orphan[]): string | undefined =>
38  orphans.length === 0 ? undefined : `DerivedData: ${orphans.length} orphan${orphans.length === 1 ? '' : 's'} · ${formatKb(totalKb(orphans))}`
39
40export function report(orphans: Orphan[]): string {
41  if (orphans.length === 0) return 'No orphan DerivedData folders.'
42  const lines = [...orphans].sort((a, b) => b.kb - a.kb).map(o => `${formatKb(o.kb).padStart(8)}  ${o.name}  <- ${o.workspace}`)
43
44  return [`${statusLine(orphans)}`, ...lines, '', 'Run /deriveddata-janitor clean to delete them.'].join('\n')
45}
46
47// `du -sk` prints "<kb>\t<path>"; 0 when unreadable.
48export function parseDuKb(stdout: string): number {
49  const kb = Number.parseInt(stdout.trim().split(/\s+/)[0] ?? '', 10)
50
51  return Number.isFinite(kb) && kb >= 0 ? kb : 0
52}
53