SLOPSHOPPER

cys

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

newguardtool
v0.2.2MITupdated 2026-10-08koenvdheide/cys
A shopper browsing a rack in a slop shop
README

CYS

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.

Examples

  • Ask Claude for a second opinion from another model's CLI. The prompt, answer and log files it writes are listed at the end of the turn, and Claude deletes them.
  • Ask Claude why CI fails only on Linux. The scratch copy of the project it tests in a container is listed at the end of the turn, and Claude deletes it.
  • Ask Claude to split a task across subagents. Their brief and report files are cleaned up the same way. A file registered by mistake also shows up in the list; Claude keeps it and says why.

Install

Needs Claude Code v2.1.287 or later.

claude plugin marketplace add koenvdheide/agent-tools
claude plugin install cys@agent-tools

Troubleshooting

  • Nothing gets registered: check that /plugin lists cys as an active mod.
  • A path comes back "not registered": the message gives the reason. Paths must be absolute in the machine's native form (C:\... on Windows).
  • No cleanup request: CYS waits until running background tasks finish.

Report problems at github.com/koenvdheide/cys/issues.

Development

claude plugin test .
claude plugin validate . --strict
Source 1 files
hooks/register.ts 107 lines
1import 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