A pane that lists the files a delete command would remove, with a keep/delete toggle per file and Confirm or Refuse buttons.

Two small mods for Claude Code.
| Mod | What it does |
|---|---|
delete-guard | While the clean-generated-artifacts skill runs (from the moment it loads until that turn ends), holds any delete command (rm, rmdir, unlink, shred, trash, git rm, git clean, find -delete) and opens a pane listing every file. Toggle each file between delete and keep, then Confirm or Refuse all. Files marked keep are dropped from the command when it is retried. Open the pane by hand with /delete-guard. |
octopus-crew | Draws Clawd, Claude Code's pixel mascot, above the prompt for the current model (Claude orange), advisor and subagent models, each live Claude Code subagent, and optionally each firstmate ship, scout, or secondmate. Firstmate octopuses show the task id and state colour. |
At a Claude Code prompt in a terminal:
/plugin install delete-guard --marketplace EmanueleNene/claude-mods
/plugin install octopus-crew --marketplace EmanueleNene/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads in every session).
delete-guard cannot wait open for a click, so the first delete attempt is held and Claude is told your decision afterwards, then re-runs the command. It only sees deletions run through the shell tool.octopus-crew reads the models when it loads, so a later /model or /advisor change shows after the next reload. Its firstmateHome plugin option is unset by default; set it to the captain's firstmate home (/home/emanuele/firstmate) to show crew. Leave it unset to disable crew octopuses.<firstmateHome>/state about every five seconds, reads .meta and .status files, and never changes or steers fleet tasks. It shows kind=ship, kind=scout, and kind=secondmate workers (including Pi-harness workers dispatched by firstmate); secondmates are labelled secondmate · <task-id>. working, decision-waiting, done and endpoint-gone states use distinct hues; completed tasks stay dimmed until their .meta file disappears. Nothing from the fleet is added to model-facing context.agent.spawn until their turn.complete. A Pi subagent that is not launched as a firstmate state-directory worker is not observable from this Claude Code mod; the mod does not invent a signal for it.claude plugin validate delete-guard
claude plugin test delete-guard
claude --plugin-dir ./octopus-crewhooks/register.tsx 307 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DeleteItem, DeleteRequest } from '../types'
5
6const PANE = 'delete-guard'
7const requests = atom({ plugin: 'delete-guard', key: 'requests' } as const, [])
8// The guard holds new deletions only while this skill's turn runs.
9const SKILL = 'clean-generated-artifacts'
10const armed = atom({ plugin: 'delete-guard', key: 'armed' } as const, false)
11
12const DELETERS = new Set(['rm', 'rmdir', 'unlink', 'shred', 'trash', 'trash-put'])
13const COMPOUND = /[;&|<>`\n]|\$\(/
14const DELETE_WORDS = /(^|[\s;&|(])(rm|rmdir|unlink|shred|trash|trash-put)\s|\s-delete(\s|$)|(^|[\s;&|(])git\s+(clean|rm)\s/
15
16type Token = { value: string; raw: string }
17type Found = { head: Token[]; targets: Token[]; items: DeleteItem[]; isSplittable: boolean }
18
19function tokenize(text: string): Token[] | undefined {
20 const out: Token[] = []
21 let value = ''
22 let raw = ''
23 let quote = ''
24 let has = false
25 for (let i = 0; i < text.length; i++) {
26 const c = text[i] as string
27 if (quote) {
28 raw += c
29 if (c === quote) quote = ''
30 else if (c === '\\' && quote === '"' && i + 1 < text.length) {
31 i++
32 value += text[i]
33 raw += text[i]
34 } else value += c
35 continue
36 }
37 if (c === "'" || c === '"') {
38 quote = c
39 raw += c
40 has = true
41 } else if (c === '\\' && i + 1 < text.length) {
42 i++
43 raw += c + text[i]
44 value += text[i]
45 has = true
46 } else if (/\s/.test(c)) {
47 if (has) out.push({ value, raw })
48 value = ''
49 raw = ''
50 has = false
51 } else {
52 value += c
53 raw += c
54 has = true
55 }
56 }
57 if (quote) return undefined
58 if (has) out.push({ value, raw })
59 return out
60}
61
62/** What a Bash command would delete, or undefined when it deletes nothing. */
63function describe(command: string): Found | undefined {
64 const tokens = COMPOUND.test(command) ? undefined : tokenize(command)
65 if (tokens && tokens.length > 0) {
66 const first = tokens[0]?.value ?? ''
67 const verb = first === 'git' && tokens[1]?.value === 'rm' ? 2 : DELETERS.has(first) ? 1 : 0
68 if (verb > 0) {
69 let at = verb
70 while (at < tokens.length) {
71 const t = (tokens[at] as Token).value
72 if (t === '--') {
73 at++
74 break
75 }
76 if (!t.startsWith('-') || t === '-') break
77 at++
78 }
79 const targets = tokens.slice(at)
80 if (targets.length === 0) return undefined
81 const items = targets.map((t): DeleteItem => ({ label: t.value, isKept: false }))
82 return { head: tokens.slice(0, at), targets, items, isSplittable: true }
83 }
84 }
85 if (DELETE_WORDS.test(command)) {
86 return {
87 head: [],
88 targets: [],
89 items: [{ label: command, isKept: false }],
90 isSplittable: false,
91 }
92 }
93 return undefined
94}
95
96function rebuild(found: Found, items: DeleteItem[]): string {
97 const kept = found.targets.filter((_, i) => !items[i]?.isKept)
98 return [...found.head, ...kept].map(t => t.raw).join(' ')
99}
100
101/** A target split into its name and the folder it sits in, relative ones resolved against cwd. */
102function place(label: string, cwd: string): { name: string; folder: string } {
103 const path = label.length > 1 ? label.replace(/[/\\]+$/, '') : label
104 const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
105 const dir = at < 0 ? '' : path.slice(0, at)
106 const isAbsolute = /^([/\\~]|[a-zA-Z]:)/.test(path)
107 return {
108 name: at < 0 ? path : path.slice(at + 1) || path,
109 folder: isAbsolute ? dir || path.slice(0, 1) : dir ? `${cwd}/${dir}` : cwd,
110 }
111}
112
113const draw = async ($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) => {
114 const { Box, Text, Button } = $.ui.resolve(e)
115 const list = await read($, requests)
116 const pending = list.filter(r => r.status === 'pending')
117 const history = list.filter(r => r.status !== 'pending').slice(-3)
118
119 const toggle = (id: string, index: number) =>
120 update($, requests, l =>
121 l.map(r =>
122 r.id === id && r.status === 'pending'
123 ? { ...r, items: r.items.map((it, i) => (i === index ? { ...it, isKept: !it.isKept } : it)) }
124 : r,
125 ),
126 )
127
128 // All selected for deletion flips to all kept; otherwise everything is selected.
129 const toggleAll = (id: string) =>
130 update($, requests, l =>
131 l.map(r => {
132 if (r.id !== id || r.status !== 'pending') return r
133 const isKept = r.items.every(it => !it.isKept)
134 return { ...r, items: r.items.map(it => ({ ...it, isKept })) }
135 }),
136 )
137
138 const decide = async (id: string, verdict: 'approved' | 'refused', isAll = false) => {
139 let settled: DeleteRequest | undefined
140 await update($, requests, l =>
141 l.map(r => {
142 if (r.id !== id || r.status !== 'pending') return r
143 if (isAll) r = { ...r, items: r.items.map(it => ({ ...it, isKept: false })) }
144 const isAllKept = r.items.every(i => i.isKept)
145 settled = { ...r, status: verdict === 'refused' || isAllKept ? 'refused' : 'approved' }
146 return settled
147 }),
148 )
149 if (!settled) return
150 const names = (isKept: boolean) =>
151 settled?.items.filter(i => i.isKept === isKept).map(i => i.label).join(', ') ?? ''
152 const text =
153 settled.status === 'refused'
154 ? `delete-guard: the user refused the deletion (${settled.command}). Nothing was deleted. Do not retry.`
155 : `delete-guard: the user confirmed the deletion. Delete: ${names(false)}.` +
156 (names(true) ? ` Keep, do not delete: ${names(true)}.` : '') +
157 ` Re-run the exact same command now: ${settled.command}`
158 const stillPending = (await read($, requests)).some(r => r.status === 'pending')
159 if (!stillPending) {
160 $.ui.status(undefined)
161 await $.ui.close({ id: PANE })
162 }
163 void $.prompt.submit({ text })
164 }
165
166 return (
167 <Box flexDirection="column">
168 {pending.length === 0 && <Text dimColor>No deletions waiting.</Text>}
169 {pending.map(r => (
170 <Box flexDirection="column" key={r.id}>
171 <Text bold>Delete {r.items.length === 1 ? 'this' : `these ${r.items.length}`}?</Text>
172 {r.items.map((item, i) => (
173 <Button
174 key={`t:${r.id}:${i}`}
175 hotkey={i < 9 ? String(i + 1) : undefined}
176 dimColor={item.isKept}
177 onPress={() => toggle(r.id, i)}
178 >
179 {item.isKept ? '[ ] keep ' : '[x] delete '}
180 {r.isSplittable ? place(item.label, r.cwd).name : item.label}
181 <Text dimColor> in {r.isSplittable ? place(item.label, r.cwd).folder : r.cwd}</Text>
182 </Button>
183 ))}
184 <Box>
185 {r.items.length > 1 && (
186 <Button key={`all:${r.id}`} hotkey="a" onPress={() => toggleAll(r.id)}>
187 {r.items.every(it => !it.isKept) ? 'Select none' : 'Select all'}
188 </Button>
189 )}
190 {r.items.length > 1 && <Text> </Text>}
191 <Button key={`ok:${r.id}`} variant="primary" hotkey="c" onPress={() => decide(r.id, 'approved')}>
192 Confirm
193 </Button>
194 <Text> </Text>
195 {r.items.length > 1 && (
196 <Button key={`del:${r.id}`} hotkey="d" onPress={() => decide(r.id, 'approved', true)}>
197 Delete all
198 </Button>
199 )}
200 {r.items.length > 1 && <Text> </Text>}
201 <Button key={`no:${r.id}`} hotkey="r" onPress={() => decide(r.id, 'refused')}>
202 Refuse all
203 </Button>
204 </Box>
205 <Text dimColor>1-9 toggle a file, a select all/none, c confirm, d delete all, r refuse all</Text>
206 </Box>
207 ))}
208 {history.map(r => (
209 <Text dimColor key={`h:${r.id}`}>
210 {r.status}: {r.items.filter(i => !i.isKept).length} deleted, {r.items.filter(i => i.isKept).length} kept
211 </Text>
212 ))}
213 </Box>
214 )
215}
216
217export const register: Register = on => {
218 on('session.start', async ($, e, next) => {
219 await $.command.register({
220 name: 'delete-guard',
221 description: 'Open the delete confirmation pane',
222 })
223
224 return next(e)
225 })
226
227 on('command.run', { command: 'delete-guard' }, async $ => {
228 await $.ui.open({ id: PANE, title: 'Delete files?', focus: true })
229
230 return { text: 'Delete pane opened.' }
231 })
232
233 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
234 const found = describe(e.command)
235 if (!found) return next(e)
236
237 const cwd = await $.session.cwd()
238 const list = await read($, requests)
239 const same = list.filter(r => r.command === e.command && r.cwd === cwd)
240
241 const done = same.find(r => r.status !== 'pending' && !r.isUsed)
242 if (done) {
243 await update($, requests, l => l.map(r => (r.id === done.id ? { ...r, isUsed: true } : r)))
244 if (done.status === 'refused') {
245 return { deny: 'delete-guard: the user refused this deletion. Nothing was deleted. Do not retry.' }
246 }
247 if (done.isSplittable && done.items.some(i => i.isKept)) {
248 return next({ ...e, command: rebuild(found, done.items) })
249 }
250 return next(e)
251 }
252
253 if (same.some(r => r.status === 'pending')) {
254 return {
255 deny: 'delete-guard: still waiting for the user to answer in the delete pane. Do not retry until you are told their decision.',
256 }
257 }
258
259 if (!(await read($, armed))) return next(e)
260
261 const request: DeleteRequest = {
262 id: e.tool_use_id,
263 command: e.command,
264 cwd,
265 isSplittable: found.isSplittable,
266 items: found.items,
267 status: 'pending',
268 isUsed: false,
269 }
270 await update($, requests, l => [...l, request].slice(-30))
271 const opened = await $.ui.open({ id: PANE, title: 'Delete files?', focus: true })
272 if (!opened.isPlaced) $.ui.toast('delete-guard: a deletion awaits you. Run /delete-guard to answer.')
273 $.ui.status('delete-guard: waiting for your answer')
274
275 return {
276 deny: 'delete-guard: this deletion needs the user to confirm in the delete pane, where they can keep individual files. Do not retry and do not ask in chat; you will be told their decision.',
277 }
278 }).catch(($, e, next) =>
279 next.called || !DELETE_WORDS.test(e.tool === 'Bash' ? e.command : '')
280 ? next(e)
281 : { deny: 'delete-guard: its guard failed, so the deletion was not run.' },
282 )
283
284 on('skill.prompt', { skill: SKILL }, async ($, e, next) => {
285 await update($, armed, () => true)
286 return next(e)
287 })
288
289 // Disarm when the main turn ends; a decision already asked for is still honoured on the retry.
290 on('turn.complete', async ($, e, next) => {
291 if (e.agentId === undefined) await update($, armed, () => false)
292 return next(e)
293 })
294
295 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => draw($, e))
296
297 // A pane opened by a hook waits undrawn on a narrow terminal, so the same question shows
298 // above the prompt at any width, until the pane itself is drawn.
299 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
300 const hasPending = (await read($, requests)).some(r => r.status === 'pending')
301 const isPaneUp = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
302 if (!hasPending || isPaneUp || e.props.hasSurvey) return next(e)
303
304 return draw($, e)
305 })
306}
307types/index.d.ts 29 lines1export type DeleteItem = {
2 /** The target as written in the command (a path, or the whole command when it cannot be split). */
3 label: string
4 /** True when the person toggled it to be kept (refused). */
5 isKept: boolean
6}
7
8export type DeleteRequest = {
9 id: string
10 command: string
11 cwd: string
12 /** True when the command is a plain `rm`-style call whose targets can be dropped one by one. */
13 isSplittable: boolean
14 items: DeleteItem[]
15 status: 'pending' | 'approved' | 'refused'
16 /** Set once the retried command has used the decision. */
17 isUsed: boolean
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'delete-guard': {
23 requests: DeleteRequest[]
24 /** True while the clean-generated-artifacts skill's turn runs. */
25 armed: boolean
26 }
27 }
28}
29