@g3nomaly/g3n-mods 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,19 +19,19 @@
19
19
  {
20
20
  "name": "custom-crab-agents",
21
21
  "source": "./mods/custom-crab-agents",
22
- "description": "Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
22
+ "description": "Draws each subagent in the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
23
23
  "version": "0.1.0"
24
24
  },
25
25
  {
26
26
  "name": "delete-guard",
27
27
  "source": "./mods/delete-guard",
28
- "description": "Denies rm -rf and other permanent deletes and points Claude to the trash.",
28
+ "description": "Holds rm -rf and other permanent deletes on a card that lists what they remove, until you press Delete or Cancel.",
29
29
  "version": "0.1.0"
30
30
  },
31
31
  {
32
32
  "name": "focus-mode",
33
33
  "source": "./mods/focus-mode",
34
- "description": "A timed focus block with a countdown, a progress bar and an end button.",
34
+ "description": "A timed focus block in which you're away: Claude asks no questions and gets no permission prompts. Countdown, progress bar and end button.",
35
35
  "version": "0.1.0"
36
36
  },
37
37
  {
package/README.md CHANGED
@@ -22,16 +22,16 @@ Then open the project in VS Code and trust the workspace. New sessions load the
22
22
  |---|---|---|
23
23
  | `agents-panel` | The agents in `.claude/agents` with their model and a run button. A running agent shows a spinner, and a toast says when it starts and finishes | `/agents-panel` |
24
24
  | `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
25
- | `custom-crab-agents` | Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time | `/crabs` |
26
- | `delete-guard` | Denies `rm -rf` and other permanent deletes and tells Claude which trash command to use instead | `/delete-guard on \| off` |
27
- | `focus-mode` | A timed focus block with a countdown, a progress bar and an end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
25
+ | `custom-crab-agents` | Adds each subagent to the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time. Needs `agents-panel`, which installing it adds | `/agents-panel` |
26
+ | `delete-guard` | Off by default: deletes run unasked. Turn it on with the switch in its pane, or `/delete-guard on`; it stays on in later sessions. While on, `rm -rf` and other permanent deletes wait on a card listing the files, their sizes and whether git can restore them, until you press **Delete** or **Cancel**. Cancel tells Claude which trash command to use instead. With nothing to draw the card on, the delete is refused | `/delete-guard on \| off` |
27
+ | `focus-mode` | A timed focus block in which you're away: Claude's questions are refused, so it picks for itself and says what it assumed, and permission prompts are refused, so it works around the step and lists it for later. Countdown, progress bar and end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
28
28
  | `handoff` | Writes a handoff with the `handoff` skill, clears the session, and attaches the handoff to your next prompt | `/handoff-new [what the next session is for]` |
29
29
  | `hide-personal-data` | Masks names, emails, phone numbers, IBANs and card numbers in replies. Hover a mask to read it | `/hide-personal-data on \| off` |
30
30
  | `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
31
31
  | `tool-timeline` | Each tool call with its duration. A denied call is struck through, next to the call that replaced it | `/tool-timeline` |
32
32
  | `you-should-know` | A card under long replies with their key point, a headline and one supporting line. It costs one small Haiku call per reply of 1500 characters or more | none |
33
33
 
34
- For `agents-panel`, `context-bar`, `custom-crab-agents` and `tool-timeline`, the command opens the pane and running it again closes it.
34
+ For `agents-panel`, `context-bar` and `tool-timeline`, the command opens the pane and running it again closes it.
35
35
 
36
36
  Three mods take settings, declared under `userConfig` in their `.claude-plugin/plugin.json`:
37
37
 
@@ -1,6 +1,6 @@
1
1
  // packages/g3n-mods/mods/agents-panel/hooks/register.tsx
2
2
  import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
4
 
5
5
  import type { AgentDef, RunningAgent } from '../types'
6
6
 
@@ -20,6 +20,27 @@ const NAMED_COLORS: Readonly<Record<string, string>> = {
20
20
  const SPINNER = ['◐', '◓', '◑', '◒']
21
21
  const DONE_STATUSES = new Set(['completed', 'failed', 'killed', 'idle'])
22
22
 
23
+ /** Key prefix of the panel's own elements; an add-on looks for it to find the panel's tree. */
24
+ export const OWN_KEY = 'agents:'
25
+
26
+ /** Key prefix an add-on's section carries, such as custom-crab-agents' crabs, so the panel knows to show it. */
27
+ export const ADDON_KEY = 'addon:'
28
+
29
+ /**
30
+ * Tells whether a drawn tree holds an element whose key starts with `prefix`.
31
+ *
32
+ * @param tree - Tree as a `ui.render` chain returned it, or one of its children.
33
+ * @param prefix - Key prefix to look for, e.g. `addon:`.
34
+ * @returns True when some element's key starts with `prefix`.
35
+ */
36
+ export function holdsKey(tree: unknown, prefix: string): boolean {
37
+ if (Array.isArray(tree)) return tree.some(child => holdsKey(child, prefix))
38
+ if (typeof tree !== 'object' || tree === null) return false
39
+ const { props, children } = tree as { props?: { key?: unknown }; children?: unknown }
40
+ if (typeof props?.key === 'string' && props.key.startsWith(prefix)) return true
41
+ return holdsKey(children, prefix)
42
+ }
43
+
23
44
  /**
24
45
  * Parses the frontmatter of a Markdown file into flat `key: value` pairs.
25
46
  *
@@ -184,8 +205,13 @@ export const register: Register = on => {
184
205
  return result
185
206
  }).catch(($, e, next) => next(e))
186
207
 
187
- on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
208
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
188
209
  const { Box, Text, Button } = $.ui.resolve(e)
210
+ // An add-on that depends on this panel, such as custom-crab-agents, may draw beneath this hook; its section joins the panel.
211
+ const below: RenderElement | undefined = await next(e).catch(() => undefined)
212
+ const addOn = below !== undefined && holdsKey(below, ADDON_KEY) ? below : undefined
213
+ /** Puts an add-on's section under the panel's own tree, when one was drawn beneath. */
214
+ const withAddOn = (own: RenderElement) => (addOn === undefined ? own : <Box flexDirection="column" gap={1}>{own}{addOn}</Box>)
189
215
  const list = await read($, agents)
190
216
  const runs = await read($, running)
191
217
  const now = await $.clock.now()
@@ -203,11 +229,11 @@ export const register: Register = on => {
203
229
  )
204
230
 
205
231
  if (list.length === 0) {
206
- return (
207
- <Box flexDirection="column" gap={1}>
232
+ return withAddOn(
233
+ <Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
208
234
  {header}
209
235
  <Text dimColor>No agents in {AGENTS_DIR} or ~/{AGENTS_DIR}. Add one with /agents.</Text>
210
- </Box>
236
+ </Box>,
211
237
  )
212
238
  }
213
239
 
@@ -265,14 +291,14 @@ export const register: Register = on => {
265
291
  )
266
292
  })()
267
293
 
268
- return (
269
- <Box flexDirection="column" gap={1}>
294
+ return withAddOn(
295
+ <Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
270
296
  {header}
271
297
  {input}
272
298
  {section('project')}
273
299
  {section('user')}
274
300
  <Text dimColor>click ▶ run to start one · /agents-panel to hide</Text>
275
- </Box>
301
+ </Box>,
276
302
  )
277
303
  })
278
304
  }
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "custom-crab-agents",
3
3
  "version": "0.1.0",
4
- "description": "Each subagent as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
4
+ "description": "Draws each subagent in the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
5
5
  "author": {
6
6
  "name": "g3ntrovert"
7
7
  },
8
- "types": "./types/index.d.ts"
8
+ "types": "./types/index.d.ts",
9
+ "dependencies": [
10
+ "agents-panel"
11
+ ]
9
12
  }
@@ -1,11 +1,15 @@
1
1
  // packages/g3n-mods/mods/custom-crab-agents/hooks/register.tsx
2
2
  import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
4
 
5
5
  import type { Crab, CrabSteps, PlannedItem } from '../types'
6
6
 
7
- const PANE = 'crabs'
8
- const TITLE = 'Crabs'
7
+ /** The agents-panel mod's pane, which this mod depends on and draws its crabs into. */
8
+ const PANEL = 'agents-panel'
9
+
10
+ /** Key prefix of the agents panel's own elements, and the one this section carries so the panel shows it. */
11
+ const PANEL_KEY = 'agents:'
12
+ const ADDON_KEY = 'addon:'
9
13
  const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
10
14
  const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
11
15
  const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
@@ -178,6 +182,21 @@ const isNumber = (v: unknown): v is number => typeof v === 'number'
178
182
  /** Tells whether `v` is a non-null object. */
179
183
  const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
180
184
 
185
+ /**
186
+ * Tells whether a drawn tree holds an element whose key starts with `prefix`.
187
+ *
188
+ * @param tree - Tree as a `ui.render` chain returned it, or one of its children.
189
+ * @param prefix - Key prefix to look for, e.g. `agents:`.
190
+ * @returns True when some element's key starts with `prefix`.
191
+ */
192
+ export function holdsKey(tree: unknown, prefix: string): boolean {
193
+ if (Array.isArray(tree)) return tree.some(child => holdsKey(child, prefix))
194
+ if (typeof tree !== 'object' || tree === null) return false
195
+ const { props, children } = tree as { props?: { key?: unknown }; children?: unknown }
196
+ if (typeof props?.key === 'string' && props.key.startsWith(prefix)) return true
197
+ return holdsKey(children, prefix)
198
+ }
199
+
181
200
  /**
182
201
  * Applies `patch` to every crab that `match` selects.
183
202
  *
@@ -190,14 +209,13 @@ async function patchCrab($: EngineInterface, match: (crab: Crab) => boolean, pat
190
209
  }
191
210
 
192
211
  /**
193
- * Records a subagent the main thread just started and shows the pane.
212
+ * Records a subagent the main thread just started.
194
213
  *
195
214
  * @param $ - Engine handle the hook received, see `EngineInterface`.
196
215
  * @param crab - New running crab to add, see `Crab`.
197
216
  */
198
217
  async function startCrab($: EngineInterface, crab: Crab): Promise<void> {
199
218
  await update($, crabs, list => [...list.filter(one => one.id !== crab.id), crab].slice(-KEEP))
200
- void $.ui.open({ id: PANE, title: TITLE })
201
219
  }
202
220
 
203
221
  /**
@@ -332,28 +350,17 @@ async function readWindow($: EngineInterface): Promise<void> {
332
350
  }
333
351
 
334
352
  /**
335
- * Registers this mod's `crabs` command, its session, tool, subagent and turn hooks, and its pane.
353
+ * Registers this mod's session, tool, subagent and turn hooks, and its section of the agents panel.
336
354
  *
337
355
  * @param on - Hook registration function the engine passes in.
338
356
  */
339
357
  export const register: Register = on => {
340
358
  on('session.start', async ($, e, next) => {
341
- await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
342
359
  await readWindow($)
343
- void $.ui.open({ id: PANE, title: TITLE })
344
360
  $.clock.every(1000, () => void tick($))
345
361
  return next(e)
346
362
  })
347
363
 
348
- on('command.run', { command: 'crabs' }, async $ => {
349
- if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
350
- await $.ui.close({ id: PANE })
351
- return { text: 'Crabs hidden.' }
352
- }
353
- await $.ui.open({ id: PANE, title: TITLE })
354
- return { text: 'Crabs shown.' }
355
- })
356
-
357
364
  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
358
365
  if (e.agentId !== undefined) return next(e)
359
366
  const startedAt = await $.clock.now()
@@ -399,13 +406,22 @@ export const register: Register = on => {
399
406
  return result
400
407
  }).catch(($, e, next) => next(e))
401
408
 
402
- on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
409
+ // Draws into the agents panel: under the panel's tree when the panel's hook lies beneath this one, or alone with an
410
+ // `addon:` key that the panel's hook picks up when it lies above. Either way the crabs end up under the agents.
411
+ on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, next) => {
403
412
  const { Box, Text } = $.ui.resolve(e)
413
+ const below: RenderElement | undefined = await next(e).catch(() => undefined)
414
+ const panel = below !== undefined && holdsKey(below, PANEL_KEY) ? below : undefined
415
+ /** Puts the crabs' section under the panel's tree, when it was drawn beneath. */
416
+ const joined = (section: RenderElement) => (panel === undefined ? section : <Box flexDirection="column" gap={1}>{panel}{section}</Box>)
417
+
404
418
  const list = await read($, crabs)
405
419
  const plan = await read($, planned)
406
420
  const window = await read($, contextWindow)
407
421
  const now = await $.clock.now()
408
- if (list.length === 0 && plan.length === 0) return <Text dimColor>No subagents yet. Each one shows up here as a crab.</Text>
422
+ if (list.length === 0 && plan.length === 0) {
423
+ return joined(<Box key={`${ADDON_KEY}crabs`}><Text dimColor>No subagents yet. Each one shows up here as a crab.</Text></Box>)
424
+ }
409
425
 
410
426
  const barWidth = Math.max(120, (e.props.bodyColumns - 10) * 7)
411
427
  /**
@@ -485,8 +501,9 @@ export const register: Register = on => {
485
501
  const running = list.filter(crab => crab.status === 'running')
486
502
  const done = list.filter(crab => crab.status !== 'running')
487
503
 
488
- return (
489
- <Box flexDirection="column">
504
+ return joined(
505
+ <Box key={`${ADDON_KEY}crabs`} flexDirection="column">
506
+ <Text bold>Subagents</Text>
490
507
  {running.map(card)}
491
508
  {done.length > 0 && <Text dimColor>▾ Finished · {done.length}</Text>}
492
509
  {done.slice().reverse().map(card)}
@@ -503,7 +520,7 @@ export const register: Register = on => {
503
520
  </Box>
504
521
  </Box>
505
522
  ))}
506
- </Box>
523
+ </Box>,
507
524
  )
508
525
  })
509
526
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delete-guard",
3
3
  "version": "0.1.0",
4
- "description": "Denies rm -rf and other permanent deletes and points Claude to the trash.",
4
+ "description": "Holds rm -rf and other permanent deletes on a card that lists what they remove, until you press Delete or Cancel.",
5
5
  "author": {
6
6
  "name": "g3ntrovert"
7
7
  },
@@ -1 +1 @@
1
- { "modules": ["./register.ts"] }
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,585 @@
1
+ // packages/g3n-mods/mods/delete-guard/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { Answer, BlastRadius, Deletion, DoomedFile } from '../types'
6
+
7
+ const isOn = atom({ plugin: 'delete-guard', key: 'isOn' } as const, false)
8
+
9
+ const PANE = 'delete-guard'
10
+ const TITLE = 'Delete guard'
11
+ const HOLD_TITLE = 'Delete?'
12
+
13
+ /** Key in the plugin's store that remembers the switch between sessions. */
14
+ const STORE_KEY = 'isOn'
15
+
16
+ /** How long a delete waits on the card before it is refused unanswered. */
17
+ const HOLD_MS = 5 * 60_000
18
+
19
+ /** Most files one delete's walk counts before it stops. */
20
+ const MAX_FILES = 5000
21
+
22
+ /** File rows the card shows before its "Show more" button. */
23
+ const SHOWN_FILES = 8
24
+
25
+ /** Approximate pixel width of one cell of the editor's code font. */
26
+ const CELL_PX = 7
27
+
28
+ const RED = '#e06c6c'
29
+ const ORANGE = '#e0775a'
30
+ const TRACK = '#2b3240'
31
+ const CHIP = '#4a2626'
32
+ const WARN_BG = '#3a2222'
33
+ const WARN_EDGE = '#7a3b3b'
34
+ const UNTRACKED_BG = '#6b3a22'
35
+
36
+ /** Shell syntax that starts a new command: `&&`, `||`, `;`, `|`, a newline, `$(` and a backtick. */
37
+ const SEGMENT_BREAK = /&&|\|\||;|\||\n|\$\(|`/
38
+
39
+ /** Words that run the command after them: `sudo rm`, `xargs -0 rm`, `env X=1 rm`. */
40
+ const PREFIX = /^(?:\w+=\S*\s+|(?:sudo|command|exec|nice|nohup|time|env)\s+(?:-\S+\s+)*|xargs\s+(?:-\S+\s+)*)+/
41
+
42
+ const DELETERS = new Set(['rm', 'shred', 'unlink', 'rimraf', 'srm'])
43
+
44
+ /** Trash commands in order of preference, with how each one is spelled. */
45
+ const TRASH_COMMANDS = [
46
+ { probe: 'trash', spell: 'trash' },
47
+ { probe: 'trash-put', spell: 'trash-put' },
48
+ { probe: 'gio', spell: 'gio trash' },
49
+ ] as const
50
+
51
+ /**
52
+ * Splits a shell command line into its commands, each trimmed.
53
+ *
54
+ * @param command - Shell command line, e.g. `cd build && rm -rf dist`.
55
+ * @returns The non-empty commands in order.
56
+ */
57
+ function segments(command: string): string[] {
58
+ return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
59
+ }
60
+
61
+ /**
62
+ * Splits one command into words, dropping its prefixes and the program's directory.
63
+ *
64
+ * @param segment - One command, as `segments` returns it.
65
+ * @returns The program name first, then its arguments.
66
+ */
67
+ function words(segment: string): string[] {
68
+ const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
69
+ return [program.split('/').pop() ?? program, ...rest]
70
+ }
71
+
72
+ /**
73
+ * Picks the operands of a command: every word that is not a flag, and every word after `--`.
74
+ *
75
+ * @param args - Words after the program name.
76
+ * @returns The operands in order.
77
+ */
78
+ function operands(args: readonly string[]): string[] {
79
+ const end = args.indexOf('--')
80
+ const flagged = end === -1 ? args : args.slice(0, end)
81
+ const after = end === -1 ? [] : args.slice(end + 1)
82
+ return [...flagged.filter(arg => !arg.startsWith('-')), ...after]
83
+ }
84
+
85
+ /**
86
+ * @brief Finds the first permanent delete in a shell command line.
87
+ *
88
+ * Recognises `rm`, `shred`, `unlink`, `rimraf`, `find -delete`, `find -exec rm`
89
+ * and a forced `git clean`.
90
+ *
91
+ * @param command - Full shell command line, as the Bash tool received it.
92
+ * @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
93
+ */
94
+ export function findDeletion(command: string): Deletion | undefined {
95
+ for (const segment of segments(command)) {
96
+ const [program = '', ...args] = words(segment)
97
+ if (DELETERS.has(program)) {
98
+ return { segment, kind: 'files', paths: operands(args) }
99
+ }
100
+ if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
101
+ return { segment, kind: 'find', paths: [] }
102
+ }
103
+ if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
104
+ return { segment, kind: 'git-clean', paths: [] }
105
+ }
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ /**
111
+ * Builds the command Claude should run instead of a permanent delete.
112
+ *
113
+ * @param deletion - Delete to replace, see `Deletion`.
114
+ * @param trash - Trash command spelled as this machine has it, e.g. `gio trash`.
115
+ * @returns The suggested command, with `<paths>` where no path was named.
116
+ */
117
+ function insteadOf(deletion: Deletion, trash: string): string {
118
+ switch (deletion.kind) {
119
+ case 'files':
120
+ return deletion.paths.length > 0 ? `${trash} ${deletion.paths.join(' ')}` : `${trash} <paths>`
121
+ case 'find':
122
+ return `${deletion.segment.replace(/\s-delete\b/, '').replace(/-exec(?:dir)?\s+.*$/, '')} -exec ${trash} {} +`.replace(/\s+/g, ' ')
123
+ case 'git-clean':
124
+ return `git clean -n to list the files, then ${trash} <paths>`
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Finds the first trash command on this machine's PATH.
130
+ *
131
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
132
+ * @returns The command as it should be spelled, or `undefined` when none is installed.
133
+ */
134
+ async function detectTrash($: EngineInterface): Promise<string | undefined> {
135
+ for (const candidate of TRASH_COMMANDS) {
136
+ const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
137
+ if (found?.exitCode === 0) return candidate.spell
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ /**
143
+ * Strips one pair of matching quotes around a shell word.
144
+ *
145
+ * @param word - Word as the command spelled it, e.g. `"build dir"`.
146
+ * @returns The word without its quotes.
147
+ */
148
+ function unquote(word: string): string {
149
+ return /^(['"]).*\1$/.test(word) ? word.slice(1, -1) : word
150
+ }
151
+
152
+ /**
153
+ * Writes a path relative to the session's directory when it lies inside it.
154
+ *
155
+ * @param cwd - Session's directory, absolute.
156
+ * @param path - Path to shorten, absolute or relative.
157
+ * @returns The path without `./` and without the `cwd` prefix.
158
+ */
159
+ function relativeTo(cwd: string, path: string): string {
160
+ const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`
161
+ const inside = path.startsWith(prefix) ? path.slice(prefix.length) : path
162
+ return inside.replace(/^(\.\/)+/, '')
163
+ }
164
+
165
+ /**
166
+ * Turns a shell glob's last segment into a regular expression: `*`, `?` and `[...]`.
167
+ *
168
+ * @param glob - One path segment, e.g. `*.log`.
169
+ * @returns The matching expression, anchored at both ends.
170
+ */
171
+ function globRegExp(glob: string): RegExp {
172
+ const body = glob.replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
173
+ return new RegExp(`^${body}$`)
174
+ }
175
+
176
+ /**
177
+ * Expands one operand of a delete into the paths it names: `~` to the home directory, and a glob in its last segment.
178
+ *
179
+ * @param $ - Engine handle the hook received.
180
+ * @param operand - Operand as the command spelled it.
181
+ * @param home - Home directory, for `~`.
182
+ * @returns The paths, empty when a glob matches nothing.
183
+ */
184
+ async function expandOperand($: EngineInterface, operand: string, home: string): Promise<string[]> {
185
+ const path = unquote(operand).replace(/^~(?=\/|$)/, home)
186
+ if (!/[*?[]/.test(path)) return [path]
187
+ const cut = path.lastIndexOf('/')
188
+ const dir = cut === -1 ? '.' : path.slice(0, cut) || '/'
189
+ const pattern = globRegExp(path.slice(cut + 1))
190
+ const entries = await $.fs.list(dir).catch(() => [])
191
+ return entries
192
+ .filter(entry => pattern.test(entry.name) && !entry.name.startsWith('.'))
193
+ .map(entry => (cut === -1 ? entry.name : `${path.slice(0, cut)}/${entry.name}`))
194
+ }
195
+
196
+ /**
197
+ * Walks a path as a recursive delete would and adds every file under it; links count as files and are not followed.
198
+ *
199
+ * @param $ - Engine handle the hook received.
200
+ * @param path - File or directory to walk.
201
+ * @param found - Files found so far, as path and size; added to.
202
+ * @returns False once `MAX_FILES` is reached, so the caller stops.
203
+ */
204
+ async function walk($: EngineInterface, path: string, found: { path: string; size: number }[]): Promise<boolean> {
205
+ const stat = await $.fs.stat(path).catch(() => undefined)
206
+ if (stat === undefined) return true
207
+ if (stat.isLink || stat.kind !== 'dir') {
208
+ found.push({ path, size: stat.isLink ? 0 : stat.size })
209
+ return found.length < MAX_FILES
210
+ }
211
+ for (const entry of await $.fs.list(path).catch(() => [])) {
212
+ const child = `${path.replace(/\/$/, '')}/${entry.name}`
213
+ if (entry.kind === 'dir' && !entry.isLink) {
214
+ if (!(await walk($, child, found))) return false
215
+ } else {
216
+ found.push({ path: child, size: entry.size })
217
+ if (found.length >= MAX_FILES) return false
218
+ }
219
+ }
220
+ return true
221
+ }
222
+
223
+ /**
224
+ * Lists the paths a `find` delete or a forced `git clean` would remove, by running the same command without deleting.
225
+ *
226
+ * @param $ - Engine handle the hook received.
227
+ * @param deletion - A delete of kind `find` or `git-clean`.
228
+ * @returns The paths, or `undefined` when the listing failed.
229
+ */
230
+ async function listedPaths($: EngineInterface, deletion: Deletion): Promise<string[] | undefined> {
231
+ const argv = words(deletion.segment).map(unquote)
232
+ if (deletion.kind === 'git-clean') {
233
+ const flags = argv.slice(2)
234
+ .filter(arg => arg !== '--force')
235
+ .map(arg => (/^-[^-]/.test(arg) ? arg.replace(/f/g, '') : arg))
236
+ .filter(arg => arg !== '-')
237
+ const ran = await $.process.run(['git', 'clean', '-n', ...flags], { timeoutMs: 10_000 }).catch(() => undefined)
238
+ if (ran?.exitCode !== 0) return undefined
239
+ return ran.stdout.split('\n').map(line => line.replace(/^Would remove /, '').trim()).filter(line => line !== '')
240
+ }
241
+ const kept: string[] = []
242
+ for (let i = 0; i < argv.length; i++) {
243
+ const arg = argv[i] ?? ''
244
+ if (arg === '-delete') continue
245
+ if (arg === '-exec' || arg === '-execdir') {
246
+ while (i < argv.length && !/^(\\?;|\+)$/.test(argv[i] ?? '')) i++
247
+ continue
248
+ }
249
+ kept.push(arg)
250
+ }
251
+ const ran = await $.process.run([...kept, '-print'], { timeoutMs: 10_000 }).catch(() => undefined)
252
+ if (ran?.exitCode !== 0) return undefined
253
+ return ran.stdout.split('\n').filter(line => line !== '')
254
+ }
255
+
256
+ /**
257
+ * Finds the files under `paths` that git tracks, which `git checkout` can bring back.
258
+ *
259
+ * @param $ - Engine handle the hook received.
260
+ * @param paths - Roots of the delete, relative to the session's directory or absolute.
261
+ * @returns Tracked paths relative to the session's directory; empty outside a repository.
262
+ */
263
+ async function trackedFiles($: EngineInterface, paths: readonly string[]): Promise<Set<string>> {
264
+ const ran = await $.process.run(['git', 'ls-files', '-z', '--', ...paths], { timeoutMs: 10_000 }).catch(() => undefined)
265
+ if (ran?.exitCode !== 0) return new Set()
266
+ return new Set(ran.stdout.split('\0').filter(path => path !== ''))
267
+ }
268
+
269
+ /**
270
+ * @brief Measures what a delete would remove: every file, its size, and whether git can restore it.
271
+ *
272
+ * @param $ - Engine handle the hook received.
273
+ * @param deletion - Delete to measure, see `findDeletion`.
274
+ * @param cwd - Session's directory, absolute.
275
+ * @param home - Home directory, for `~`.
276
+ * @returns The files largest first, with their total; `isKnown` false when they could not be listed.
277
+ */
278
+ async function measure($: EngineInterface, deletion: Deletion, cwd: string, home: string): Promise<BlastRadius> {
279
+ const roots = deletion.kind === 'files'
280
+ ? (await Promise.all(deletion.paths.map(operand => expandOperand($, operand, home)))).flat()
281
+ : await listedPaths($, deletion)
282
+ if (roots === undefined) return { files: [], total: 0, isCut: false, isKnown: false }
283
+
284
+ const found: { path: string; size: number }[] = []
285
+ let isCut = false
286
+ for (const root of roots) {
287
+ if (!(await walk($, root, found))) {
288
+ isCut = true
289
+ break
290
+ }
291
+ }
292
+ const tracked = deletion.kind === 'git-clean' || roots.length === 0 ? new Set<string>() : await trackedFiles($, roots)
293
+ const files: DoomedFile[] = found
294
+ .map(file => {
295
+ const path = relativeTo(cwd, file.path)
296
+ return { path, size: file.size, isTracked: tracked.has(path) }
297
+ })
298
+ .sort((a, b) => b.size - a.size)
299
+ return { files, total: files.reduce((sum, file) => sum + file.size, 0), isCut, isKnown: true }
300
+ }
301
+
302
+ /** Formats bytes as the card shows them: `B`, whole `KB`, or `MB` and `GB` with one decimal. */
303
+ function bytes(size: number): string {
304
+ if (size < 1024) return `${size} B`
305
+ if (size < 1024 ** 2) return `${Math.round(size / 1024)} KB`
306
+ if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(1)} MB`
307
+ return `${(size / 1024 ** 3).toFixed(1)} GB`
308
+ }
309
+
310
+ /** Counts files as `1 file` or `N files`, with `N+` when the walk was cut. */
311
+ function fileCount(count: number, isCut = false): string {
312
+ return `${count}${isCut ? '+' : ''} file${count === 1 && !isCut ? '' : 's'}`
313
+ }
314
+
315
+ /**
316
+ * Says whether git can bring the files back, the warning under the card's header.
317
+ *
318
+ * @param radius - What the delete removes.
319
+ * @returns One sentence about how many of the files git tracks.
320
+ */
321
+ function restoreNote(radius: BlastRadius): string {
322
+ if (!radius.isKnown) return "Couldn't list what this deletes, so its size is unknown."
323
+ const tracked = radius.files.filter(file => file.isTracked).length
324
+ if (tracked === 0) return "None of these are tracked by git, so they can't be restored."
325
+ if (tracked === radius.files.length) return 'All of these are tracked by git: their committed versions can be restored, uncommitted changes cannot.'
326
+ return `${tracked} of these are tracked by git and can be restored; the other ${radius.files.length - tracked} cannot.`
327
+ }
328
+
329
+ /** The folder a file sits in, as the card groups them: `.` for the session's directory. */
330
+ function folderOf(path: string): string {
331
+ const cut = path.lastIndexOf('/')
332
+ return cut === -1 ? '.' : path.slice(0, cut)
333
+ }
334
+
335
+ /**
336
+ * Builds the SVG of a size bar: a track with a fill `share` of its width.
337
+ *
338
+ * @param width - Width of the track in pixels.
339
+ * @param share - Filled part, from 0 to 1.
340
+ * @param color - Fill color.
341
+ * @returns The SVG markup.
342
+ */
343
+ function barSvg(width: number, share: number, color: string): string {
344
+ const fill = Math.max(3, share * width)
345
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="4" fill="${TRACK}"/><rect width="${fill.toFixed(1)}" height="8" rx="4" fill="${color}"/></svg>`
346
+ }
347
+
348
+ /** A delete held for the person's answer: what it runs, what it removes, and how to settle it. */
349
+ type Held = {
350
+ segment: string
351
+ folder: string
352
+ radius: BlastRadius
353
+ settle: (answer: Answer | 'timeout') => void
354
+ }
355
+
356
+ /** Deletes waiting on the card, oldest first; the card shows the first. */
357
+ const held: Held[] = []
358
+
359
+ /** The card lists every file instead of the first `SHOWN_FILES`. */
360
+ let isExpanded = false
361
+
362
+ /**
363
+ * Turns the guard on or off, for this session and every later one.
364
+ *
365
+ * @param $ - Engine handle the command or press came through.
366
+ * @param isGuarding - True to hold deletes on the card, false to let them run unasked.
367
+ */
368
+ async function setGuard($: EngineInterface, isGuarding: boolean): Promise<void> {
369
+ await update($, isOn, () => isGuarding)
370
+ await $.store.set(STORE_KEY, isGuarding)
371
+ }
372
+
373
+ /**
374
+ * Shows the switch again once no delete waits: the pane's title goes back from the card's.
375
+ *
376
+ * @param $ - Engine handle the hook received.
377
+ */
378
+ async function showSwitch($: EngineInterface): Promise<void> {
379
+ if (held.length === 0) await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
380
+ $.ui.invalidate('ui.render')
381
+ }
382
+
383
+ /**
384
+ * Answers the delete the card shows, then shows the next one or the switch.
385
+ *
386
+ * @param $ - Engine handle the press came through.
387
+ * @param answer - What the person chose.
388
+ */
389
+ async function answerFirst($: EngineInterface, answer: Answer): Promise<void> {
390
+ const first = held.shift()
391
+ isExpanded = false
392
+ first?.settle(answer)
393
+ await showSwitch($)
394
+ }
395
+
396
+ /**
397
+ * @brief Holds a delete until the person answers its card, it waits `HOLD_MS`, or Claude's call is stopped.
398
+ *
399
+ * @param $ - Engine handle the hook received.
400
+ * @param card - The delete and what it removes.
401
+ * @param signal - Aborts the wait when Claude's call is stopped.
402
+ * @returns `delete`, `cancel`, or `timeout` when no one answered in time.
403
+ */
404
+ async function hold($: EngineInterface, card: Omit<Held, 'settle'>, signal: AbortSignal | undefined): Promise<Answer | 'timeout'> {
405
+ let settle: (answer: Answer | 'timeout') => void = () => {}
406
+ const answered = new Promise<Answer | 'timeout'>(resolve => { settle = resolve })
407
+ const entry: Held = { ...card, settle }
408
+ held.push(entry)
409
+
410
+ /** Takes this delete off the card with `answer`, unless it was answered already. */
411
+ const drop = (answer: Answer | 'timeout'): void => {
412
+ const index = held.indexOf(entry)
413
+ if (index === -1) return
414
+ held.splice(index, 1)
415
+ settle(answer)
416
+ void showSwitch($)
417
+ }
418
+ const timer = $.clock.after(HOLD_MS, () => drop('timeout'))
419
+ const onAbort = (): void => drop('cancel')
420
+ signal?.addEventListener('abort', onAbort, { once: true })
421
+
422
+ // The switch's pane is usually open already: retitle and focus it, then redraw it as the card.
423
+ if (held.length === 1) await $.ui.open({ id: PANE, title: HOLD_TITLE, focus: true })
424
+ $.ui.invalidate('ui.render')
425
+
426
+ const answer = await answered
427
+ timer.cancel()
428
+ signal?.removeEventListener('abort', onAbort)
429
+ return answer
430
+ }
431
+
432
+ /**
433
+ * Registers this mod's `delete-guard` command, its session hook, its Bash tool hook, and its pane: the switch, or the card while a delete waits.
434
+ *
435
+ * @param on - Hook registration function the engine passes in.
436
+ */
437
+ export const register: Register = on => {
438
+ let trash: string | undefined
439
+ let home = ''
440
+
441
+ on('session.start', async ($, e, next) => {
442
+ trash = await detectTrash($)
443
+ const found = await $.process.run(['printenv', 'HOME']).catch(() => undefined)
444
+ home = found?.stdout.trim() ?? ''
445
+ const remembered = await $.store.get(STORE_KEY).catch(() => undefined)
446
+ await update($, isOn, () => remembered === true)
447
+ await $.command.register({
448
+ name: 'delete-guard',
449
+ description: 'Turn the permanent-delete guard on or off; it stays that way in later sessions',
450
+ argumentHint: 'on | off',
451
+ })
452
+ void $.ui.open({ id: PANE, title: TITLE })
453
+ return next(e)
454
+ })
455
+
456
+ on('command.run', { command: 'delete-guard' }, async ($, e) => {
457
+ const wanted = e.args.trim()
458
+ if (wanted === 'on' || wanted === 'off') await setGuard($, wanted === 'on')
459
+ const isGuarding = await read($, isOn)
460
+ return { text: `delete-guard is ${isGuarding ? 'on: permanent deletes wait for your answer' : 'off: permanent deletes run unasked'}.` }
461
+ })
462
+
463
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
464
+ if (!(await read($, isOn))) return next(e)
465
+ const deletion = findDeletion(e.command)
466
+ if (deletion === undefined) return next(e)
467
+
468
+ const spelled = trash ?? 'trash'
469
+ const install = trash === undefined ? ' (install trash-cli: sudo apt install trash-cli)' : ''
470
+ const instead = `\`${insteadOf(deletion, spelled)}\`${install}`
471
+
472
+ // Nothing can draw the card, as in a -p run or VS Code without the viewer: refuse outright.
473
+ if ((await $.session.surfaces()).length === 0) {
474
+ return { deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: ${instead}.` }
475
+ }
476
+
477
+ const cwd = await $.session.cwd()
478
+ const radius = await measure($, deletion, cwd, home)
479
+ if (radius.isKnown && radius.files.length === 0) return next(e)
480
+
481
+ const folder = cwd.split('/').filter(Boolean).pop() ?? cwd
482
+ const answer = await hold($, { segment: deletion.segment, folder, radius }, next.signal)
483
+ if (answer === 'delete') return next(e)
484
+ if (answer === 'timeout') {
485
+ return { deny: `delete-guard: no one answered the delete card within ${HOLD_MS / 60_000} minutes, so \`${deletion.segment}\` did not run. Ask the user before trying again.` }
486
+ }
487
+ return { deny: `delete-guard: the user cancelled \`${deletion.segment}\`. Do not delete these files without asking; to remove them, move them to the trash: ${instead}.` }
488
+ }).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
489
+
490
+ on('ui.close', async ($, e, next) => {
491
+ // Closing the card by hand answers every delete it holds with Cancel.
492
+ if (e.id === PANE && e.origin.kind === 'person') {
493
+ for (const entry of held.splice(0)) entry.settle('cancel')
494
+ isExpanded = false
495
+ }
496
+ return next(e)
497
+ })
498
+
499
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
500
+ const { Box, Text, Button } = $.ui.resolve(e)
501
+ const card = held[0]
502
+ if (card === undefined) {
503
+ const isGuarding = await read($, isOn)
504
+ return (
505
+ <Box flexDirection="row" columnGap={2} alignItems="center">
506
+ <Button key="switch" label={isGuarding ? '● on' : '○ off'} variant={isGuarding ? 'primary' : 'secondary'} onPress={() => setGuard($, !isGuarding)} />
507
+ <Text dimColor wrap="truncate-end">{isGuarding ? 'Deletes wait here for Delete or Cancel.' : 'Deletes run without asking.'}</Text>
508
+ </Box>
509
+ )
510
+ }
511
+ const { radius } = card
512
+ const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.22))
513
+
514
+ /** Draws a size bar with `share` of it filled: an SVG, or block characters in the terminal. */
515
+ const bar = (share: number, color: string) => {
516
+ if (e.surface === 'terminal') {
517
+ const filled = Math.max(1, Math.round(share * barCells))
518
+ return <Text><Text color={color}>{'▬'.repeat(filled)}</Text><Text color={TRACK}>{'▬'.repeat(barCells - filled)}</Text></Text>
519
+ }
520
+ const { Svg } = $.ui.resolve(e)
521
+ const width = barCells * CELL_PX
522
+ return <Svg source={barSvg(width, share, color)} alt={`${Math.round(share * 100)}%`} width={width} height={8} />
523
+ }
524
+
525
+ // Folders by size, each with its largest files under it, until SHOWN_FILES file rows are used.
526
+ const folders = new Map<string, DoomedFile[]>()
527
+ for (const file of radius.files) folders.set(folderOf(file.path), [...(folders.get(folderOf(file.path)) ?? []), file])
528
+ const groups = [...folders.entries()]
529
+ .map(([name, files]) => ({ name, files, size: files.reduce((sum, file) => sum + file.size, 0) }))
530
+ .sort((a, b) => b.size - a.size)
531
+ const largest = Math.max(1, groups[0]?.size ?? 1)
532
+ let budget = isExpanded ? radius.files.length : SHOWN_FILES
533
+ const rows = groups.flatMap(group => {
534
+ if (budget <= 0) return []
535
+ const files = group.files.slice(0, budget)
536
+ budget -= files.length
537
+ return [
538
+ <Box key={`dir:${group.name}`} flexDirection="row" columnGap={1} marginTop={1}>
539
+ <Box flexGrow={1}><Text wrap="truncate-end" bold>▭ {group.name === '.' ? './' : `${group.name}/`}</Text></Box>
540
+ <Box width={9} justifyContent="flex-end"><Text dimColor>{fileCount(group.files.length)}</Text></Box>
541
+ <Box width={8} justifyContent="flex-end"><Text bold>{bytes(group.size)}</Text></Box>
542
+ {bar(group.size / largest, '#d8d4c8')}
543
+ </Box>,
544
+ ...files.map(file => (
545
+ <Box key={`file:${file.path}`} flexDirection="row" columnGap={1} paddingLeft={2}>
546
+ <Box flexGrow={1}>
547
+ <Text wrap="truncate-end"><Text dimColor>▯ </Text><Text backgroundColor={file.isTracked ? undefined : UNTRACKED_BG}>{file.path.slice(file.path.lastIndexOf('/') + 1)}</Text></Text>
548
+ </Box>
549
+ <Box width={8} justifyContent="flex-end"><Text dimColor>{bytes(file.size)}</Text></Box>
550
+ {bar(file.size / largest, '#9a968c')}
551
+ </Box>
552
+ )),
553
+ ]
554
+ })
555
+ const hidden = isExpanded ? 0 : Math.max(0, radius.files.length - SHOWN_FILES)
556
+ const count = radius.isKnown ? `${fileCount(radius.files.length, radius.isCut)} · ${bytes(radius.total)}` : 'unknown files'
557
+ const waiting = held.length > 1 ? ` · ${held.length - 1} more waiting` : ''
558
+
559
+ return (
560
+ <Box flexDirection="column">
561
+ <Box flexDirection="row" columnGap={2} alignItems="center">
562
+ <Text bold color={RED} backgroundColor={CHIP}> 🗑 </Text>
563
+ <Box flexDirection="column" flexShrink={1}>
564
+ <Text wrap="truncate-end"><Text bold>{card.segment}</Text><Text dimColor> will delete </Text><Text bold color={ORANGE}>{count}</Text></Text>
565
+ <Text dimColor>in {card.folder}{waiting}</Text>
566
+ </Box>
567
+ </Box>
568
+ <Box borderStyle="round" borderColor={WARN_EDGE} backgroundColor={WARN_BG} paddingX={1} marginTop={1}>
569
+ <Text color={RED}>⛉ {restoreNote(radius)}</Text>
570
+ </Box>
571
+ {rows}
572
+ {hidden > 0 && (
573
+ <Box marginTop={1}>
574
+ <Button key="more" plain onPress={() => { isExpanded = true; $.ui.invalidate('ui.render') }}><Text dimColor>Show {hidden} more</Text></Button>
575
+ </Box>
576
+ )}
577
+ <Box flexDirection="row" columnGap={2} alignItems="center" marginTop={1}>
578
+ <Button key="delete" variant="primary" label={`Delete ${fileCount(radius.files.length, radius.isCut)}`} onPress={() => answerFirst($, 'delete')} />
579
+ <Button key="cancel" label="Cancel" onPress={() => answerFirst($, 'cancel')} />
580
+ <Text dimColor>Held by delete-guard</Text>
581
+ </Box>
582
+ </Box>
583
+ )
584
+ })
585
+ }
@@ -7,8 +7,33 @@ export type Deletion = {
7
7
  paths: string[]
8
8
  }
9
9
 
10
+ /** One file a delete would remove: its path relative to the session's directory, its bytes, and whether git tracks it. */
11
+ export type DoomedFile = {
12
+ path: string
13
+ size: number
14
+ isTracked: boolean
15
+ }
16
+
17
+ /**
18
+ * What a delete would remove, as the card shows it.
19
+ *
20
+ * @property files - Every file found, largest first.
21
+ * @property total - Bytes of all of them.
22
+ * @property isCut - The walk stopped early, so there are more than `files` lists.
23
+ * @property isKnown - The files could be listed at all; false when the listing failed.
24
+ */
25
+ export type BlastRadius = {
26
+ files: DoomedFile[]
27
+ total: number
28
+ isCut: boolean
29
+ isKnown: boolean
30
+ }
31
+
32
+ /** The person's answer on the card: run the delete, or refuse it. */
33
+ export type Answer = 'delete' | 'cancel'
34
+
10
35
  declare module 'claude-code' {
11
36
  interface PluginState {
12
- 'delete-guard': { isOff: boolean }
37
+ 'delete-guard': { isOn: boolean }
13
38
  }
14
39
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "focus-mode",
3
3
  "version": "0.1.0",
4
- "description": "A timed focus block with a countdown, a progress bar and an end button.",
4
+ "description": "A timed focus block in which you're away: Claude asks no questions and gets no permission prompts. Countdown, progress bar and end button.",
5
5
  "author": {
6
6
  "name": "g3ntrovert"
7
7
  },
@@ -107,7 +107,20 @@ async function tick($: EngineInterface): Promise<void> {
107
107
  }
108
108
 
109
109
  /**
110
- * Registers this mod's `focus-mode` command, its session hook, its tick, and its pane.
110
+ * Finds the running block, if any: while one runs the person is away from the keyboard.
111
+ *
112
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
113
+ * @returns The block, or `null` when none runs or its time is up.
114
+ */
115
+ async function awayBlock($: EngineInterface): Promise<FocusBlock | null> {
116
+ const current = await read($, block)
117
+ if (current === null) return null
118
+ return (await $.clock.now()) < current.endsAt ? current : null
119
+ }
120
+
121
+ /**
122
+ * Registers this mod's `focus-mode` command, its session hook, its tick, its pane, and the hooks that keep Claude
123
+ * from asking anything during a block: questions are refused, and so are permission prompts.
111
124
  *
112
125
  * @param on - Hook registration function the engine passes in.
113
126
  * @param options - Plugin options; `minutes` sets the default block length.
@@ -119,7 +132,7 @@ export const register: Register = (on, options) => {
119
132
  on('session.start', async ($, e, next) => {
120
133
  await $.command.register({
121
134
  name: 'focus-mode',
122
- description: `Start a focus block (${defaultMinutes} min by default), or end the running one`,
135
+ description: `Start a focus block (${defaultMinutes} min by default) in which Claude asks you nothing, or end the running one`,
123
136
  argumentHint: '[minutes] | end',
124
137
  })
125
138
  void $.ui.open({ id: PANE, title: TITLE })
@@ -141,6 +154,25 @@ export const register: Register = (on, options) => {
141
154
  return { text: `Focus block started: ${minutes} min.` }
142
155
  })
143
156
 
157
+ // While a block runs the person is away: a question Claude puts to them is refused, so Claude decides on its own.
158
+ on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
159
+ const away = await awayBlock($)
160
+ if (away === null) return next(e)
161
+ return { deny: `The user is in a focus block until ${wallTime(away.endsAt)} and cannot answer. Pick the option you would recommend, say in your reply what you assumed, and carry on.` }
162
+ }).catch(($, e, next) => next(e))
163
+
164
+ // A permission prompt would wait for the person too: refuse it, so Claude works around the step or leaves it for later.
165
+ on('classic.PermissionRequest', async ($, e, next) => {
166
+ const away = await awayBlock($)
167
+ if (away === null) return next(e)
168
+ return {
169
+ decision: {
170
+ behavior: 'deny',
171
+ message: `The user is in a focus block until ${wallTime(away.endsAt)} and cannot approve ${e.tool_name}. Do what you can without it, and list this step at the end of your reply for the user to approve later.`,
172
+ },
173
+ }
174
+ }).catch(($, e, next) => next(e))
175
+
144
176
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
145
177
  const { Box, Text, Button } = $.ui.resolve(e)
146
178
  const current = await read($, block)
@@ -176,7 +208,7 @@ export const register: Register = (on, options) => {
176
208
  <Text bold>{clock(remaining)}</Text>
177
209
  <Box flexDirection="column" flexGrow={1} gap={0}>
178
210
  {squares}
179
- <Text dimColor>focus block · {current.minutes} min · ends {wallTime(current.endsAt)}</Text>
211
+ <Text dimColor>away until {wallTime(current.endsAt)} · Claude asks you nothing</Text>
180
212
  </Box>
181
213
  <Button key="end" label="end focus" onPress={() => endFocus($, false)} />
182
214
  </Box>
@@ -14,6 +14,9 @@ const MINES = 10
14
14
  const BEST_KEY = 'best-seconds'
15
15
  /** How long a turn runs before the mod suggests a game, once per session. */
16
16
  const HINT_AFTER_MS = 60_000
17
+ /** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`, `✹`) on any surface. */
18
+ const CELL_WIDTH = 2
19
+
17
20
  const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
18
21
 
19
22
  /**
@@ -224,21 +227,21 @@ export const register: Register = on => {
224
227
  const isOver = board.status === 'won' || board.status === 'lost'
225
228
 
226
229
  /**
227
- * Draws one cell: a number, a flag, a mine, or a button that reveals or flags it.
230
+ * Draws what a cell shows: a number, a flag, a mine, or a button that reveals or flags it.
228
231
  *
229
232
  * @param cell - Cell number to draw.
230
- * @returns The cell element.
233
+ * @returns The cell's content.
231
234
  */
232
- const cellView = (cell: number) => {
235
+ const cellFace = (cell: number) => {
233
236
  const isRevealed = board.revealed.includes(cell)
234
237
  const isMine = board.mines.includes(cell)
235
238
  if (isRevealed || (isOver && isMine)) {
236
- if (isMine) return <Text color="#e06c6c">✹ </Text>
239
+ if (isMine) return <Text color="#e06c6c">✹</Text>
237
240
  const n = adjacentMines(board, cell)
238
- return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : `${n} `}</Text>
241
+ return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
239
242
  }
240
243
  const label = board.flagged.includes(cell) ? '⚑' : '■'
241
- if (isOver) return <Text dimColor>{label} </Text>
244
+ if (isOver) return <Text dimColor>{label}</Text>
242
245
  return (
243
246
  <Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
244
247
  <Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
@@ -246,6 +249,11 @@ export const register: Register = on => {
246
249
  )
247
250
  }
248
251
 
252
+ // Every cell sits in a box of one fixed width, so revealing a cell never moves the others: glyphs differ in width.
253
+ const cellView = (cell: number) => (
254
+ <Box width={CELL_WIDTH} flexShrink={0} justifyContent="center">{cellFace(cell)}</Box>
255
+ )
256
+
249
257
  const rows = Array.from({ length: board.size }, (_, r) => (
250
258
  <Box flexDirection="row" columnGap={1}>
251
259
  {Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g3nomaly/g3n-mods",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "The g3n Claude Code mods: panes, status lines, commands and hooks, installed with @g3nomaly/mods.",
5
5
  "type": "module",
6
6
  "files": [
@@ -15,7 +15,7 @@
15
15
  "types": "bun scripts/sync-types.ts"
16
16
  },
17
17
  "devDependencies": {
18
- "@g3nomaly/core": "^0.3.1",
18
+ "@g3nomaly/core": "^0.3.3",
19
19
  "@types/bun": "^1.4.0",
20
20
  "typescript": "~7.0.2",
21
21
  "zod": "^4.4.3"
@@ -1,151 +0,0 @@
1
- // packages/g3n-mods/mods/delete-guard/hooks/register.ts
2
- import { atom, read, update } from 'claude-code'
3
- import type { EngineInterface, Register } from 'claude-code'
4
-
5
- import type { Deletion } from '../types'
6
-
7
- const isOff = atom({ plugin: 'delete-guard', key: 'isOff' } as const, false)
8
-
9
- /** Shell syntax that starts a new command: `&&`, `||`, `;`, `|`, a newline, `$(` and a backtick. */
10
- const SEGMENT_BREAK = /&&|\|\||;|\||\n|\$\(|`/
11
-
12
- /** Words that run the command after them: `sudo rm`, `xargs -0 rm`, `env X=1 rm`. */
13
- const PREFIX = /^(?:\w+=\S*\s+|(?:sudo|command|exec|nice|nohup|time|env)\s+(?:-\S+\s+)*|xargs\s+(?:-\S+\s+)*)+/
14
-
15
- const DELETERS = new Set(['rm', 'shred', 'unlink', 'rimraf', 'srm'])
16
-
17
- /** Trash commands in order of preference, with how each one is spelled. */
18
- const TRASH_COMMANDS = [
19
- { probe: 'trash', spell: 'trash' },
20
- { probe: 'trash-put', spell: 'trash-put' },
21
- { probe: 'gio', spell: 'gio trash' },
22
- ] as const
23
-
24
- /**
25
- * Splits a shell command line into its commands, each trimmed.
26
- *
27
- * @param command - Shell command line, e.g. `cd build && rm -rf dist`.
28
- * @returns The non-empty commands in order.
29
- */
30
- function segments(command: string): string[] {
31
- return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
32
- }
33
-
34
- /**
35
- * Splits one command into words, dropping its prefixes and the program's directory.
36
- *
37
- * @param segment - One command, as `segments` returns it.
38
- * @returns The program name first, then its arguments.
39
- */
40
- function words(segment: string): string[] {
41
- const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
42
- return [program.split('/').pop() ?? program, ...rest]
43
- }
44
-
45
- /**
46
- * Picks the operands of a command: every word that is not a flag, and every word after `--`.
47
- *
48
- * @param args - Words after the program name.
49
- * @returns The operands in order.
50
- */
51
- function operands(args: readonly string[]): string[] {
52
- const end = args.indexOf('--')
53
- const flagged = end === -1 ? args : args.slice(0, end)
54
- const after = end === -1 ? [] : args.slice(end + 1)
55
- return [...flagged.filter(arg => !arg.startsWith('-')), ...after]
56
- }
57
-
58
- /**
59
- * @brief Finds the first permanent delete in a shell command line.
60
- *
61
- * Recognises `rm`, `shred`, `unlink`, `rimraf`, `find -delete`, `find -exec rm`
62
- * and a forced `git clean`.
63
- *
64
- * @param command - Full shell command line, as the Bash tool received it.
65
- * @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
66
- */
67
- export function findDeletion(command: string): Deletion | undefined {
68
- for (const segment of segments(command)) {
69
- const [program = '', ...args] = words(segment)
70
- if (DELETERS.has(program)) {
71
- return { segment, kind: 'files', paths: operands(args) }
72
- }
73
- if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
74
- return { segment, kind: 'find', paths: [] }
75
- }
76
- if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
77
- return { segment, kind: 'git-clean', paths: [] }
78
- }
79
- }
80
- return undefined
81
- }
82
-
83
- /**
84
- * Builds the command Claude should run instead of a permanent delete.
85
- *
86
- * @param deletion - Delete to replace, see `Deletion`.
87
- * @param trash - Trash command spelled as this machine has it, e.g. `gio trash`.
88
- * @returns The suggested command, with `<paths>` where no path was named.
89
- */
90
- function insteadOf(deletion: Deletion, trash: string): string {
91
- switch (deletion.kind) {
92
- case 'files':
93
- return deletion.paths.length > 0 ? `${trash} ${deletion.paths.join(' ')}` : `${trash} <paths>`
94
- case 'find':
95
- return `${deletion.segment.replace(/\s-delete\b/, '').replace(/-exec(?:dir)?\s+.*$/, '')} -exec ${trash} {} +`.replace(/\s+/g, ' ')
96
- case 'git-clean':
97
- return `git clean -n to list the files, then ${trash} <paths>`
98
- }
99
- }
100
-
101
- /**
102
- * Finds the first trash command on this machine's PATH.
103
- *
104
- * @param $ - Engine handle the hook received, see `EngineInterface`.
105
- * @returns The command as it should be spelled, or `undefined` when none is installed.
106
- */
107
- async function detectTrash($: EngineInterface): Promise<string | undefined> {
108
- for (const candidate of TRASH_COMMANDS) {
109
- const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
110
- if (found?.exitCode === 0) return candidate.spell
111
- }
112
- return undefined
113
- }
114
-
115
- /**
116
- * Registers this mod's `delete-guard` command, its session hook, and its Bash tool hook.
117
- *
118
- * @param on - Hook registration function the engine passes in.
119
- */
120
- export const register: Register = on => {
121
- let trash: string | undefined
122
-
123
- on('session.start', async ($, e, next) => {
124
- trash = await detectTrash($)
125
- await $.command.register({
126
- name: 'delete-guard',
127
- description: 'Turn the permanent-delete guard on or off for this session',
128
- argumentHint: 'on | off',
129
- })
130
- return next(e)
131
- })
132
-
133
- on('command.run', { command: 'delete-guard' }, async ($, e) => {
134
- const wanted = e.args.trim()
135
- if (wanted === 'on' || wanted === 'off') await update($, isOff, () => wanted === 'off')
136
- const off = await read($, isOff)
137
- return { text: `delete-guard is ${off ? 'off: permanent deletes run' : 'on: permanent deletes are denied'}.` }
138
- })
139
-
140
- on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
141
- if (await read($, isOff)) return next(e)
142
- const deletion = findDeletion(e.command)
143
- if (deletion === undefined) return next(e)
144
-
145
- const spelled = trash ?? 'trash'
146
- const install = trash === undefined ? ' (install trash-cli: sudo apt install trash-cli)' : ''
147
- return {
148
- deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: \`${insteadOf(deletion, spelled)}\`${install}.`,
149
- }
150
- }).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
151
- }