Tracks the temporary files Claude creates and asks it to clean them up at the end of each turn.

Claude Code leaves scratch files all over the place as it works, e.g. probe scripts, captured output, intermediate data. This can add up to gigabytes of temp files and clutters your project directories. CYS gives Claude a register tool for those files. At the end of each turn it lists the registered files that still exist and asks Claude to clean them up. CYS never deletes anything itself, the Claude session judges which files are safe to be deleted.
Subagents register their own files or report them to the main agent. Registrations stay in the plugin's own store, and CYS makes no network calls; see PRIVACY.md.
Needs Claude Code v2.1.287 or later.
claude plugin marketplace add koenvdheide/agent-tools
claude plugin install cys@agent-tools
/plugin lists cys as an active mod.C:\... on Windows).Report problems at github.com/koenvdheide/cys/issues.
claude plugin test .
claude plugin validate . --stricthooks/register.ts 107 lines1import type { Register } from 'claude-code'
2
3const TOOL = 'mcp__cys__register'
4
5const DESCRIPTION =
6 'Register temporary files or directories you created for this task, by exact absolute path, right after creating them. ' +
7 'Use the native path form of this machine (C:\\... on Windows, /... elsewhere). ' +
8 'Also register the paths your subagents report. Never register deliverables or files that existed before the task. ' +
9 'CYS never deletes anything: at the end of the turn it lists the registered paths still on disk once, so you can delete what is no longer needed.'
10
11const SUBAGENT_CONTEXT =
12 'CYS: if you have the mcp__cys__register tool, register each temporary file or directory you create with it. ' +
13 'Otherwise list their absolute paths in your final report, so the main agent registers them.'
14
15// The store refuses keys over 256 characters, so keys carry a hash of the
16// path and the value carries the path itself.
17const hash = (text: string) => {
18 let h1 = 0xdeadbeef
19 let h2 = 0x41c6ce57
20 for (let i = 0; i < text.length; i++) {
21 const c = text.charCodeAt(i)
22 h1 = Math.imul(h1 ^ c, 2654435761)
23 h2 = Math.imul(h2 ^ c, 1597334677)
24 }
25 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
26 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
27 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16)
28}
29
30const cleanupText =(paths: string[]) =>
31 'CYS has registered temporary paths to review:\n' +
32 paths.join('\n') +
33 '\nReview ownership and whether each is still needed. Delete only task-created disposable paths; ' +
34 'preserve user, pre-existing, tracked, and needed files. Report any path you keep and why. ' +
35 'Do this cleanup pass once, then finish.'
36
37// $.fs.exists rejects network locations; registration refuses these spellings
38// so the agent gets a clear reason up front. Stop does not depend on it.
39const rejection = (path: string, isWindows: boolean) => {
40 if (/[\r\n]/.test(path)) return 'contains a line break'
41 if (/^[\\/][\\/]/.test(path)) return 'network or device path'
42 if (/^[A-Za-z]:(?![\\/])/.test(path)) return 'drive-relative path'
43 if (isWindows ? /^[A-Za-z]:[\\/]/.test(path) : path.startsWith('/')) return undefined
44 return isWindows ? 'not an absolute Windows path such as C:\\...' : 'not an absolute path'
45}
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 await $.tool.register({
50 name: 'register',
51 description: DESCRIPTION,
52 inputSchema: {
53 type: 'object',
54 properties: { paths: { type: 'array', items: { type: 'string' }, minItems: 1 } },
55 required: ['paths'],
56 },
57 })
58 return next(e)
59 })
60
61 on('classic.SubagentStart', async ($, e, next) => {
62 const result = await next(e)
63 return { ...result, additionalContext: [...(result.additionalContext ?? []), SUBAGENT_CONTEXT] }
64 })
65
66 on('tool.call', { tool: TOOL }, async ($, e) => {
67 const session = await $.session.id()
68 const isWindows = /^([A-Za-z]:|\\\\)/.test(await $.session.cwd())
69 const paths: unknown = (e as { paths?: unknown }).paths
70 const lines: string[] = []
71 for (const path of Array.isArray(paths) ? paths : []) {
72 const reason = typeof path === 'string' ? rejection(path, isWindows) : 'not a string'
73 if (reason) {
74 lines.push(`not registered (${reason}): ${String(path)}`)
75 continue
76 }
77 try {
78 await $.store.set(`${session}\n${hash(path)}`, path)
79 lines.push(`registered: ${path}`)
80 } catch {
81 lines.push(`not registered (store refused it): ${path}`)
82 }
83 }
84 return { result: lines.length > 0 ? lines.join('\n') : 'No paths given.' }
85 })
86
87 on('classic.Stop', async ($, e, next) => {
88 const result = await next(e)
89 if (e.stop_hook_active || (e.background_tasks?.length ?? 0) > 0 || result.preventContinuation) return result
90 const prefix = `${e.session_id}\n`
91 const present: string[] = []
92 for (const key of await $.store.keys()) {
93 if (!key.startsWith(prefix)) continue
94 const path = await $.store.get(key)
95 await $.store.delete(key)
96 if (typeof path !== 'string') continue
97 const isPresent = await $.fs.stat(path).then(
98 () => true,
99 () => $.fs.exists(path).catch(() => true),
100 )
101 if (isPresent) present.push(path)
102 }
103 if (present.length === 0) return result
104 return { ...result, additionalContext: [...(result.additionalContext ?? []), cleanupText(present)] }
105 })
106}
107