@g3nomaly/g3n-mods 0.0.0-stage → 0.1.0

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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +68 -0
  2. package/LICENSE +95 -0
  3. package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
  4. package/mods/agents-panel/hooks/hooks.json +1 -0
  5. package/mods/agents-panel/hooks/register.tsx +224 -0
  6. package/mods/agents-panel/types/index.d.ts +24 -0
  7. package/mods/context-bar/.claude-plugin/plugin.json +9 -0
  8. package/mods/context-bar/hooks/hooks.json +1 -0
  9. package/mods/context-bar/hooks/register.tsx +187 -0
  10. package/mods/context-bar/types/index.d.ts +25 -0
  11. package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
  12. package/mods/custom-crab-agents/hooks/hooks.json +1 -0
  13. package/mods/custom-crab-agents/hooks/register.tsx +369 -0
  14. package/mods/custom-crab-agents/types/index.d.ts +45 -0
  15. package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
  16. package/mods/delete-guard/hooks/hooks.json +1 -0
  17. package/mods/delete-guard/hooks/register.ts +117 -0
  18. package/mods/delete-guard/types/index.d.ts +14 -0
  19. package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
  20. package/mods/focus-mode/hooks/hooks.json +1 -0
  21. package/mods/focus-mode/hooks/register.tsx +147 -0
  22. package/mods/focus-mode/types/index.d.ts +14 -0
  23. package/mods/handoff/.claude-plugin/plugin.json +9 -0
  24. package/mods/handoff/hooks/hooks.json +1 -0
  25. package/mods/handoff/hooks/register.tsx +119 -0
  26. package/mods/handoff/types/index.d.ts +15 -0
  27. package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
  28. package/mods/hide-personal-data/hooks/hooks.json +1 -0
  29. package/mods/hide-personal-data/hooks/register.tsx +163 -0
  30. package/mods/hide-personal-data/types/index.d.ts +17 -0
  31. package/mods/mini-game/.claude-plugin/plugin.json +9 -0
  32. package/mods/mini-game/hooks/hooks.json +1 -0
  33. package/mods/mini-game/hooks/register.tsx +211 -0
  34. package/mods/mini-game/types/index.d.ts +21 -0
  35. package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
  36. package/mods/tool-timeline/hooks/hooks.json +1 -0
  37. package/mods/tool-timeline/hooks/register.tsx +259 -0
  38. package/mods/tool-timeline/types/index.d.ts +25 -0
  39. package/mods/tsconfig.json +17 -0
  40. package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
  41. package/mods/you-should-know/hooks/hooks.json +1 -0
  42. package/mods/you-should-know/hooks/register.tsx +98 -0
  43. package/mods/you-should-know/types/index.d.ts +15 -0
  44. package/package.json +25 -4
  45. package/README.md +0 -3
@@ -0,0 +1,369 @@
1
+ // packages/g3n-mods/mods/custom-crab-agents/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { Crab, CrabSteps, PlannedItem } from '../types'
6
+
7
+ const PANE = 'crabs'
8
+ const TITLE = 'Crabs'
9
+ const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
10
+ const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
11
+ const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
12
+
13
+ const KEEP = 30
14
+ const PIXEL = 4
15
+
16
+ /** Effort level → tier name, color and hat. */
17
+ const TIERS: Readonly<Record<string, { name: string; color: string; hat: keyof typeof HATS }>> = {
18
+ low: { name: 'light', color: '#3fa66b', hat: 'cap' },
19
+ medium: { name: 'medium', color: '#4c8bf5', hat: 'sailor' },
20
+ high: { name: 'careful', color: '#e0a030', hat: 'hardhat' },
21
+ xhigh: { name: 'heavy', color: '#e05a4f', hat: 'beanie' },
22
+ max: { name: 'heavy', color: '#e05a4f', hat: 'beanie' },
23
+ }
24
+ const DEFAULT_TIER = { name: 'medium', color: '#4c8bf5', hat: 'sailor' as const }
25
+
26
+ /** Sprite rows, 12 pixels wide; each letter is a palette color, `.` is empty. */
27
+ const BODY = [
28
+ '...k....k...',
29
+ 'C..b....b..C',
30
+ 'CC.bbbbbb.CC',
31
+ '.CbbbbbbbbC.',
32
+ '..bbbbbbbb..',
33
+ '..bbbbbbbb..',
34
+ '..b.b..b.b..',
35
+ '.b..b..b..b.',
36
+ ]
37
+ const HATS = {
38
+ cap: ['...gggg.....', '..gggggg....', '..ggggggggg.'],
39
+ sailor: ['...wwwwww...', '..wwwwwwww..', '..uuuuuuuu..'],
40
+ hardhat: ['....yyyy....', '...yyyyyy...', '.yyyyyyyyyy.'],
41
+ beanie: ['.....t......', '...nnnnnn...', '..nnnnnnnn..'],
42
+ } as const
43
+ const PALETTE: Readonly<Record<string, string>> = {
44
+ b: '#e07a5f', C: '#c8553d', k: '#1e1e1e',
45
+ g: '#3fa66b', w: '#f0f0f0', u: '#4c8bf5', y: '#f2c14e', t: '#e8e8e8', n: '#7a4a2a',
46
+ }
47
+
48
+ /** Price per million tokens: input, output, cache read, cache write. First match wins. */
49
+ const PRICES: ReadonlyArray<[RegExp, [number, number, number, number]]> = [
50
+ [/fable|mythos/, [10, 50, 0.25, 12.5]],
51
+ [/opus-5-5/, [4, 20, 0.2, 5]],
52
+ [/opus/, [5, 25, 0.5, 6.25]],
53
+ [/sonnet-5/, [2, 10, 0.2, 2.5]],
54
+ [/sonnet/, [3, 15, 0.3, 3.75]],
55
+ [/haiku-5/, [0.1, 0.5, 0.01, 0.125]],
56
+ [/haiku/, [1, 5, 0.1, 1.25]],
57
+ ]
58
+
59
+ function tierOf(effort: string | undefined) {
60
+ return (effort === undefined ? undefined : TIERS[effort]) ?? DEFAULT_TIER
61
+ }
62
+
63
+ /** A model id or alias as people say it: `claude-opus-5-5` → `Opus 5.5`, `haiku` → `Haiku`. */
64
+ export function modelName(model: string): string {
65
+ const match = /(opus|sonnet|haiku|fable|mythos)(?:-(\d+))?(?:-(\d+))?/i.exec(model)
66
+ if (match === null) return model
67
+ const family = (match[1] ?? '').charAt(0).toUpperCase() + (match[1] ?? '').slice(1).toLowerCase()
68
+ const version = [match[2], match[3]].filter(part => part !== undefined && part.length <= 2).join('.')
69
+ return version.length > 0 ? `${family} ${version}` : family
70
+ }
71
+
72
+ /** Estimated dollars for a usage record, or `undefined` when the model is not priced. */
73
+ export function costOf(model: string, usage: Record<string, unknown>): number | undefined {
74
+ const price = PRICES.find(([match]) => match.test(model))?.[1]
75
+ if (price === undefined) return undefined
76
+ const n = (key: string) => (typeof usage[key] === 'number' ? (usage[key] as number) : 0)
77
+ return (n('input_tokens') * price[0] + n('output_tokens') * price[1]
78
+ + n('cache_read_input_tokens') * price[2] + n('cache_creation_input_tokens') * price[3]) / 1_000_000
79
+ }
80
+
81
+ function formatTokens(tokens: number): string {
82
+ return tokens >= 10_000 ? `${Math.round(tokens / 1000)}k` : tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)
83
+ }
84
+
85
+ function minutes(ms: number): string {
86
+ const s = Math.max(0, Math.round(ms / 1000))
87
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
88
+ }
89
+
90
+ /** The crab as one SVG: hat rows for the tier on top of the body; a planned crab is drawn grey. */
91
+ function crabSvg(hat: keyof typeof HATS, isPlanned: boolean): string {
92
+ const rows = [...HATS[hat], ...BODY]
93
+ const rects: string[] = []
94
+ rows.forEach((row, y) => {
95
+ for (let x = 0; x < row.length; x++) {
96
+ const color = PALETTE[row.charAt(x)]
97
+ if (color !== undefined) rects.push(`<rect x="${x * PIXEL}" y="${y * PIXEL}" width="${PIXEL}" height="${PIXEL}" fill="${color}"/>`)
98
+ }
99
+ })
100
+ const width = 12 * PIXEL
101
+ const height = rows.length * PIXEL
102
+ const filter = isPlanned ? ' opacity="0.45" style="filter:grayscale(1)"' : ''
103
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"${filter}>${rects.join('')}</svg>`
104
+ }
105
+
106
+ function progressSvg(width: number, share: number, color: string): string {
107
+ const w = Math.max(0, Math.min(1, share)) * width
108
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="5" viewBox="0 0 ${width} 5"><rect width="${width}" height="5" rx="2" fill="#2b3240"/><rect width="${w.toFixed(1)}" height="5" rx="2" fill="${color}"/></svg>`
109
+ }
110
+
111
+ function field<T>(value: unknown, key: string, guard: (v: unknown) => v is T): T | undefined {
112
+ if (typeof value !== 'object' || value === null) return undefined
113
+ const found = (value as Record<string, unknown>)[key]
114
+ return guard(found) ? found : undefined
115
+ }
116
+ const isString = (v: unknown): v is string => typeof v === 'string'
117
+ const isNumber = (v: unknown): v is number => typeof v === 'number'
118
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
119
+
120
+ async function patchCrab($: EngineInterface, match: (crab: Crab) => boolean, patch: (crab: Crab) => Crab): Promise<void> {
121
+ await update($, crabs, list => list.map(crab => (match(crab) ? patch(crab) : crab)))
122
+ }
123
+
124
+ /** Records a subagent the main thread just started and shows the pane. */
125
+ async function startCrab($: EngineInterface, crab: Crab): Promise<void> {
126
+ await update($, crabs, list => [...list.filter(one => one.id !== crab.id), crab].slice(-KEEP))
127
+ void $.ui.open({ id: PANE, title: TITLE })
128
+ }
129
+
130
+ /** Settles a crab from the Agent tool's result: its usage, model and time, or that it went to the background. */
131
+ async function finishCrab($: EngineInterface, id: string, result: unknown, isError: boolean): Promise<void> {
132
+ const status = field(result, 'status', isString)
133
+ const agentId = field(result, 'agentId', isString)
134
+ if (status === 'async_launched') {
135
+ await patchCrab($, crab => crab.id === id, crab => ({ ...crab, agentId: agentId ?? crab.agentId }))
136
+ return
137
+ }
138
+ const usage = field(result, 'usage', isRecord) ?? {}
139
+ const now = await $.clock.now()
140
+ await patchCrab($, crab => crab.id === id, crab => {
141
+ const model = field(result, 'resolvedModel', isString) ?? crab.model
142
+ return {
143
+ ...crab,
144
+ agentId: agentId ?? crab.agentId,
145
+ model,
146
+ status: isError ? 'failed' : 'finished',
147
+ durationMs: field(result, 'totalDurationMs', isNumber) ?? now - crab.startedAt,
148
+ tokens: field(result, 'totalTokens', isNumber) ?? crab.tokens,
149
+ costUsd: costOf(model, usage) ?? crab.costUsd,
150
+ steps: crab.steps.total > 0 ? { ...crab.steps, done: crab.steps.total, current: undefined } : crab.steps,
151
+ }
152
+ })
153
+ }
154
+
155
+ /** Settles a background crab when its own loop completes. */
156
+ async function finishBackground($: EngineInterface, agentId: string, usage: Record<string, unknown> | undefined): Promise<void> {
157
+ const now = await $.clock.now()
158
+ await patchCrab($, crab => crab.agentId === agentId && crab.status === 'running', crab => ({
159
+ ...crab,
160
+ status: 'finished',
161
+ durationMs: now - crab.startedAt,
162
+ tokens: usage === undefined ? crab.tokens : ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
163
+ .reduce((sum, key) => sum + (typeof usage[key] === 'number' ? (usage[key] as number) : 0), 0),
164
+ costUsd: usage === undefined ? crab.costUsd : costOf(crab.model, usage),
165
+ }))
166
+ }
167
+
168
+ /** Ties a subagent's id to the newest running crab of its type that has none yet. */
169
+ async function bindAgent($: EngineInterface, agentId: string, agentType: string): Promise<void> {
170
+ await update($, crabs, list => {
171
+ if (list.some(crab => crab.agentId === agentId)) return list
172
+ const index = list.map(crab => crab.status === 'running' && crab.agentId === undefined && crab.subagentType === agentType).lastIndexOf(true)
173
+ return index === -1 ? list : list.map((crab, i) => (i === index ? { ...crab, agentId } : crab))
174
+ })
175
+ }
176
+
177
+ /** Turns a todo list into steps (a subagent's own list) or planned items (the main thread's). */
178
+ async function readTodos($: EngineInterface, todos: unknown, agentId: string | undefined): Promise<void> {
179
+ if (!Array.isArray(todos)) return
180
+ const items = todos.filter(isRecord).map(todo => ({
181
+ content: field(todo, 'content', isString) ?? '',
182
+ status: field(todo, 'status', isString) ?? 'pending',
183
+ activeForm: field(todo, 'activeForm', isString),
184
+ }))
185
+
186
+ if (agentId !== undefined) {
187
+ const steps: CrabSteps = {
188
+ done: items.filter(item => item.status === 'completed').length,
189
+ total: items.length,
190
+ current: items.find(item => item.status === 'in_progress')?.activeForm,
191
+ }
192
+ await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, steps }))
193
+ return
194
+ }
195
+
196
+ const open: PlannedItem[] = []
197
+ items.forEach((item, i) => {
198
+ if (item.status !== 'pending') return
199
+ const after = items.slice(0, i).map((other, j) => (other.status === 'completed' ? 0 : j + 1)).filter(n => n > 0)
200
+ open.push({ index: i + 1, content: item.content, after })
201
+ })
202
+ await update($, planned, () => open)
203
+ }
204
+
205
+ /** Counts a subagent's model requests and keeps its model and effort current. */
206
+ async function countStep($: EngineInterface, agentId: string, model: string, effort: string | undefined): Promise<void> {
207
+ await patchCrab($, crab => crab.agentId === agentId, crab => ({ ...crab, requests: crab.requests + 1, model, effort: effort ?? crab.effort }))
208
+ }
209
+
210
+ /** Redraws once a second while a crab runs, so its time moves. */
211
+ async function tick($: EngineInterface): Promise<void> {
212
+ if ((await read($, crabs)).some(crab => crab.status === 'running')) $.ui.invalidate('ui.render')
213
+ }
214
+
215
+ async function readWindow($: EngineInterface): Promise<void> {
216
+ const usage = await $.session.usage().catch(() => undefined)
217
+ if (usage !== undefined && usage.context.window > 0) await update($, contextWindow, () => usage.context.window)
218
+ }
219
+
220
+ export const register: Register = on => {
221
+ on('session.start', async ($, e, next) => {
222
+ await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
223
+ await readWindow($)
224
+ $.clock.every(1000, () => void tick($))
225
+ return next(e)
226
+ })
227
+
228
+ on('command.run', { command: 'crabs' }, async $ => {
229
+ if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
230
+ await $.ui.close({ id: PANE })
231
+ return { text: 'Crabs hidden.' }
232
+ }
233
+ await $.ui.open({ id: PANE, title: TITLE })
234
+ return { text: 'Crabs shown.' }
235
+ })
236
+
237
+ on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
238
+ if (e.agentId !== undefined) return next(e)
239
+ const startedAt = await $.clock.now()
240
+ await startCrab($, {
241
+ id: e.tool_use_id,
242
+ title: e.description,
243
+ subagentType: e.subagent_type ?? 'general-purpose',
244
+ model: e.model ?? 'inherit',
245
+ effort: e.effort === undefined ? undefined : String(e.effort),
246
+ status: 'running',
247
+ startedAt,
248
+ steps: { done: 0, total: 0 },
249
+ requests: 0,
250
+ })
251
+ const ran = await next(e)
252
+ if ('deny' in ran) {
253
+ await update($, crabs, list => list.filter(crab => crab.id !== e.tool_use_id))
254
+ return ran
255
+ }
256
+ await finishCrab($, e.tool_use_id, ran.result, ran.isError === true)
257
+ return ran
258
+ }).catch(($, e, next) => next(e))
259
+
260
+ on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
261
+ const ran = await next(e)
262
+ if (!('deny' in ran) && ran.isError !== true) await readTodos($, e.todos, e.agentId)
263
+ return ran
264
+ }).catch(($, e, next) => next(e))
265
+
266
+ on('classic.SubagentStart', async ($, e, next) => {
267
+ await bindAgent($, e.agent_id, e.agent_type)
268
+ return next(e)
269
+ }).catch(($, e, next) => next(e))
270
+
271
+ on('turn.step', async function* ($, e, next) {
272
+ if (e.agentId !== undefined) await countStep($, e.agentId, e.model, e.effort === undefined ? undefined : String(e.effort))
273
+ return yield* next(e)
274
+ })
275
+
276
+ on('turn.complete', async ($, e, next) => {
277
+ const result = await next(e)
278
+ if (e.agentId !== undefined) await finishBackground($, e.agentId, e.usage === undefined ? undefined : { ...e.usage })
279
+ return result
280
+ }).catch(($, e, next) => next(e))
281
+
282
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
283
+ const { Box, Text } = $.ui.resolve(e)
284
+ const list = await read($, crabs)
285
+ const plan = await read($, planned)
286
+ const window = await read($, contextWindow)
287
+ const now = await $.clock.now()
288
+ if (list.length === 0 && plan.length === 0) return <Text dimColor>No subagents yet. Each one shows up here as a crab.</Text>
289
+
290
+ const barWidth = Math.max(120, (e.props.bodyColumns - 10) * 7)
291
+ const sprite = (hat: keyof typeof HATS, isPlanned: boolean) => {
292
+ if (e.surface === 'terminal') return <Text color={isPlanned ? '#6b7280' : PALETTE.b}>🦀</Text>
293
+ const { Svg } = $.ui.resolve(e)
294
+ return <Svg source={crabSvg(hat, isPlanned)} alt="crab" width={12 * PIXEL} height={11 * PIXEL} />
295
+ }
296
+ const progress = (share: number, color: string) => {
297
+ if (e.surface === 'terminal') {
298
+ const cells = Math.max(10, e.props.bodyColumns - 10)
299
+ const filled = Math.round(Math.max(0, Math.min(1, share)) * cells)
300
+ return <Text><Text color={color}>{'━'.repeat(filled)}</Text><Text color="#2b3240">{'━'.repeat(cells - filled)}</Text></Text>
301
+ }
302
+ const { Svg } = $.ui.resolve(e)
303
+ return <Svg source={progressSvg(barWidth, share, color)} alt={`${Math.round(share * 100)}%`} width={barWidth} height={5} />
304
+ }
305
+
306
+ const card = (crab: Crab) => {
307
+ const tier = tierOf(crab.effort)
308
+ const elapsed = crab.durationMs ?? now - crab.startedAt
309
+ const tokens = crab.tokens ?? 0
310
+ const share = crab.steps.total > 0 ? crab.steps.done / crab.steps.total : crab.status === 'running' ? 0.08 + Math.min(0.8, crab.requests / 40) : 1
311
+ const stepLine = crab.steps.total > 0
312
+ ? `${crab.steps.done}/${crab.steps.total}${crab.steps.current === undefined ? '' : ` · ${crab.steps.current}`}`
313
+ : crab.status === 'running' ? `step ${crab.requests}` : `${crab.requests} step${crab.requests === 1 ? '' : 's'}`
314
+ const stats = [
315
+ tokens > 0 ? `ctx ${Math.max(1, Math.round((tokens / window) * 100))}%` : '',
316
+ tokens > 0 ? formatTokens(tokens) : '',
317
+ crab.costUsd === undefined ? '' : `≈$${crab.costUsd.toFixed(2)}`,
318
+ minutes(elapsed),
319
+ ].filter(part => part.length > 0).join(' · ')
320
+ const mark = crab.status === 'running' ? <Text color="#2f6b4a">●</Text>
321
+ : crab.status === 'failed' ? <Text color="#e06c6c">✗</Text>
322
+ : <Text color="#7cc47f">✓</Text>
323
+ return (
324
+ <Box flexDirection="row" columnGap={2} paddingY={1}>
325
+ {sprite(tier.hat, false)}
326
+ <Box flexDirection="column" flexGrow={1}>
327
+ <Box flexDirection="row" justifyContent="space-between">
328
+ <Text bold wrap="truncate-end">{crab.title}</Text>
329
+ {mark}
330
+ </Box>
331
+ <Text>
332
+ <Text color={tier.color}>{tier.name}</Text>
333
+ <Text dimColor> {modelName(crab.model)}{crab.effort === undefined ? '' : ` · ${crab.effort}`}</Text>
334
+ </Text>
335
+ <Box flexDirection="row" justifyContent="space-between">
336
+ <Text wrap="truncate-end">{stepLine}</Text>
337
+ <Text dimColor>{stats}</Text>
338
+ </Box>
339
+ {progress(share, tier.color)}
340
+ </Box>
341
+ </Box>
342
+ )
343
+ }
344
+
345
+ const running = list.filter(crab => crab.status === 'running')
346
+ const done = list.filter(crab => crab.status !== 'running')
347
+
348
+ return (
349
+ <Box flexDirection="column">
350
+ {running.map(card)}
351
+ {done.length > 0 && <Text dimColor>▾ Finished · {done.length}</Text>}
352
+ {done.slice().reverse().map(card)}
353
+ {plan.length > 0 && <Text dimColor>Planned · {plan.length}</Text>}
354
+ {plan.map(item => (
355
+ <Box flexDirection="row" columnGap={2} paddingY={1}>
356
+ {sprite('beanie', true)}
357
+ <Box flexDirection="column" flexGrow={1}>
358
+ <Box flexDirection="row" justifyContent="space-between">
359
+ <Text bold dimColor wrap="truncate-end">{item.index}. {item.content}</Text>
360
+ <Text dimColor>◷</Text>
361
+ </Box>
362
+ <Text dimColor>planned{item.after.length > 0 ? ` · after ${item.after.join(', ')}` : ''}</Text>
363
+ </Box>
364
+ </Box>
365
+ ))}
366
+ </Box>
367
+ )
368
+ })
369
+ }
@@ -0,0 +1,45 @@
1
+ // packages/g3n-mods/mods/custom-crab-agents/types/index.d.ts
2
+
3
+ /** Progress a subagent reported through its own todo list. */
4
+ export type CrabSteps = {
5
+ done: number
6
+ total: number
7
+ /** The step in progress, as its todo's active form says, e.g. `Writing hero copy` */
8
+ current?: string
9
+ }
10
+
11
+ /** One subagent drawn as a crab; times are milliseconds since the epoch. */
12
+ export type Crab = {
13
+ /** The Agent call's tool_use_id */
14
+ id: string
15
+ title: string
16
+ subagentType: string
17
+ /** Model id or alias, e.g. `claude-opus-5-5` or `haiku` */
18
+ model: string
19
+ /** `low`, `medium`, `high`, `xhigh` or `max` */
20
+ effort?: string
21
+ status: 'running' | 'finished' | 'failed'
22
+ agentId?: string
23
+ startedAt: number
24
+ durationMs?: number
25
+ tokens?: number
26
+ costUsd?: number
27
+ steps: CrabSteps
28
+ /** Model requests the subagent has made so far */
29
+ requests: number
30
+ }
31
+
32
+ /** One open item of the main thread's todo list, drawn as a planned crab. */
33
+ export type PlannedItem = {
34
+ /** 1-based position in the todo list */
35
+ index: number
36
+ content: string
37
+ /** Positions of the open items before it */
38
+ after: number[]
39
+ }
40
+
41
+ declare module 'claude-code' {
42
+ interface PluginState {
43
+ 'custom-crab-agents': { crabs: Crab[]; planned: PlannedItem[]; window: number }
44
+ }
45
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "delete-guard",
3
+ "version": "0.1.0",
4
+ "description": "Denies rm -rf and other permanent deletes and points Claude to the trash.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.ts"] }
@@ -0,0 +1,117 @@
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
+ /** Splits a shell command line into its commands, each trimmed. */
25
+ function segments(command: string): string[] {
26
+ return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
27
+ }
28
+
29
+ /** Words of a command after its prefixes, the program name without its directory. */
30
+ function words(segment: string): string[] {
31
+ const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
32
+ return [program.split('/').pop() ?? program, ...rest]
33
+ }
34
+
35
+ /** The operands of a command: every word that is not a flag, and every word after `--`. */
36
+ function operands(args: readonly string[]): string[] {
37
+ const end = args.indexOf('--')
38
+ const flagged = end === -1 ? args : args.slice(0, end)
39
+ const after = end === -1 ? [] : args.slice(end + 1)
40
+ return [...flagged.filter(arg => !arg.startsWith('-')), ...after]
41
+ }
42
+
43
+ /**
44
+ * Finds the first permanent delete in a shell command line: `rm`, `shred`,
45
+ * `unlink`, `rimraf`, `find -delete`, `find -exec rm` or a forced `git clean`.
46
+ *
47
+ * @returns the delete and what it removes, or `undefined` when nothing is deleted for good
48
+ */
49
+ export function findDeletion(command: string): Deletion | undefined {
50
+ for (const segment of segments(command)) {
51
+ const [program = '', ...args] = words(segment)
52
+ if (DELETERS.has(program)) {
53
+ return { segment, kind: 'files', paths: operands(args) }
54
+ }
55
+ if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
56
+ return { segment, kind: 'find', paths: [] }
57
+ }
58
+ if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
59
+ return { segment, kind: 'git-clean', paths: [] }
60
+ }
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ /** What Claude should run instead, spelled with the trash command this machine has. */
66
+ function insteadOf(deletion: Deletion, trash: string): string {
67
+ switch (deletion.kind) {
68
+ case 'files':
69
+ return deletion.paths.length > 0 ? `${trash} ${deletion.paths.join(' ')}` : `${trash} <paths>`
70
+ case 'find':
71
+ return `${deletion.segment.replace(/\s-delete\b/, '').replace(/-exec(?:dir)?\s+.*$/, '')} -exec ${trash} {} +`.replace(/\s+/g, ' ')
72
+ case 'git-clean':
73
+ return `git clean -n to list the files, then ${trash} <paths>`
74
+ }
75
+ }
76
+
77
+ /** First trash command found on this machine's PATH, or `undefined` when there is none. */
78
+ async function detectTrash($: EngineInterface): Promise<string | undefined> {
79
+ for (const candidate of TRASH_COMMANDS) {
80
+ const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
81
+ if (found?.exitCode === 0) return candidate.spell
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ export const register: Register = on => {
87
+ let trash: string | undefined
88
+
89
+ on('session.start', async ($, e, next) => {
90
+ trash = await detectTrash($)
91
+ await $.command.register({
92
+ name: 'delete-guard',
93
+ description: 'Turn the permanent-delete guard on or off for this session',
94
+ argumentHint: 'on | off',
95
+ })
96
+ return next(e)
97
+ })
98
+
99
+ on('command.run', { command: 'delete-guard' }, async ($, e) => {
100
+ const wanted = e.args.trim()
101
+ if (wanted === 'on' || wanted === 'off') await update($, isOff, () => wanted === 'off')
102
+ const off = await read($, isOff)
103
+ return { text: `delete-guard is ${off ? 'off: permanent deletes run' : 'on: permanent deletes are denied'}.` }
104
+ })
105
+
106
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
107
+ if (await read($, isOff)) return next(e)
108
+ const deletion = findDeletion(e.command)
109
+ if (deletion === undefined) return next(e)
110
+
111
+ const spelled = trash ?? 'trash'
112
+ const install = trash === undefined ? ' (install trash-cli: sudo apt install trash-cli)' : ''
113
+ return {
114
+ deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: \`${insteadOf(deletion, spelled)}\`${install}.`,
115
+ }
116
+ }).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
117
+ }
@@ -0,0 +1,14 @@
1
+ // packages/g3n-mods/mods/delete-guard/types/index.d.ts
2
+
3
+ /** A permanent delete found in a shell command: the command itself, its kind, and the paths it names. */
4
+ export type Deletion = {
5
+ segment: string
6
+ kind: 'files' | 'find' | 'git-clean'
7
+ paths: string[]
8
+ }
9
+
10
+ declare module 'claude-code' {
11
+ interface PluginState {
12
+ 'delete-guard': { isOff: boolean }
13
+ }
14
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "focus-mode",
3
+ "version": "0.1.0",
4
+ "description": "A timed focus block with a countdown, a progress bar and an end button.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts",
9
+ "userConfig": {
10
+ "minutes": {
11
+ "type": "number",
12
+ "title": "Minutes",
13
+ "default": 25,
14
+ "description": "Length of a focus block in minutes"
15
+ }
16
+ }
17
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }