@g3nomaly/g3n-mods 0.0.0-stage → 0.1.1

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 +277 -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 +215 -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 +508 -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 +151 -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 +184 -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 +156 -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 +196 -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 +273 -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 +311 -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 +116 -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,196 @@
1
+ // packages/g3n-mods/mods/hide-personal-data/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderChildren } from 'claude-code'
4
+
5
+ import type { PersonalKind, PersonalSpan } from '../types'
6
+
7
+ const isOff = atom({ plugin: 'hide-personal-data', key: 'isOff' } as const, false)
8
+ const knownNames = atom({ plugin: 'hide-personal-data', key: 'names' } as const, [])
9
+
10
+ const MASK = '#3b4252'
11
+ const REVEAL = '#4c566a'
12
+ const ACCENT = '#e0775a'
13
+
14
+ /** Patterns per kind, tried in this order; an earlier kind wins an overlap. */
15
+ const PATTERNS: ReadonlyArray<[PersonalKind, RegExp]> = [
16
+ ['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g],
17
+ ['iban', /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g],
18
+ ['card', /\b(?:\d{4}[ -]?){3}\d{1,4}\b/g],
19
+ ['phone', /(?<![\w.])\+?\d[\d ().-]{7,}\d(?![\w.])/g],
20
+ ]
21
+
22
+ /** Common first names; one followed by a capitalised word reads as a full name. */
23
+ const FIRST_NAMES = new Set(`
24
+ aaron adam ahmed aisha alex alexander alice amanda amir amy ana andrea andrew angela anna anne anthony arjun ben benjamin
25
+ bob brian carlos carmen carol catherine charles chen chloe chris christina christopher daniel david deepak diana diego
26
+ dmitri elena elizabeth emily emma eric erik eva fatima felix fernando francesca frank gabriel george grace hannah hans
27
+ harry helen henry hiroshi ian isabel isabella jack jacob james jan jane jason javier jennifer jessica john jonathan jose
28
+ joseph juan julia julian karen kate katherine kevin kim laura lars lauren laura leo li lin linda lisa lucas lucia luis
29
+ maria mark marta martin mary matteo matthew max mei michael michelle miguel mohammed nadia natalia nicholas nina noah
30
+ oliver olivia omar pablo paul peter pieter priya rachel rahul raj rebecca richard robert ryan sam samuel sara sarah
31
+ sebastian sophia sophie stefan steven susan thomas tim tom tomas valentina victor wei william yuki yusuf zoe
32
+ `.split(/\s+/).filter(name => name.length > 0))
33
+
34
+ const FULL_NAME = /\b([A-Z][a-z]+) ((?:van |de |von |da )?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?)\b/g
35
+ const TITLED_NAME = /\b(?:Mr|Mrs|Ms|Dr|Prof)\.? [A-Z][a-z]+(?:[-'][A-Z][a-z]+)?\b/g
36
+
37
+ /**
38
+ * Counts the digits in a text.
39
+ *
40
+ * @param text - Text to count in, e.g. a phone number.
41
+ * @returns Number of digit characters.
42
+ */
43
+ function digitCount(text: string): number {
44
+ return text.replace(/\D/g, '').length
45
+ }
46
+
47
+ /**
48
+ * Escapes the characters that have a meaning in a regular expression.
49
+ *
50
+ * @param text - Literal text to match exactly.
51
+ * @returns The text with each special character escaped.
52
+ */
53
+ function escapeRegExp(text: string): string {
54
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
55
+ }
56
+
57
+ /**
58
+ * @brief Finds personal data in a text: emails, phone numbers, IBANs, card numbers and names.
59
+ *
60
+ * Names come from `names` and from common first names followed by a surname.
61
+ * Overlapping matches keep the earlier, longer one.
62
+ *
63
+ * @param text - Text to search, a line or a whole message.
64
+ * @param names - Exact names to mask wherever they occur, e.g. `["Ada Lovelace"]`.
65
+ * @returns The matches in text order.
66
+ */
67
+ export function findPersonalData(text: string, names: readonly string[]): PersonalSpan[] {
68
+ const spans: PersonalSpan[] = []
69
+ for (const [kind, pattern] of PATTERNS) {
70
+ for (const match of text.matchAll(pattern)) {
71
+ if (kind === 'phone' && digitCount(match[0]) < 9) continue
72
+ spans.push({ kind, start: match.index, end: match.index + match[0].length })
73
+ }
74
+ }
75
+ for (const name of names) {
76
+ if (name.trim().length < 3) continue
77
+ for (const match of text.matchAll(new RegExp(`\\b${escapeRegExp(name.trim())}\\b`, 'g'))) {
78
+ spans.push({ kind: 'name', start: match.index, end: match.index + match[0].length })
79
+ }
80
+ }
81
+ for (const match of text.matchAll(FULL_NAME)) {
82
+ if (FIRST_NAMES.has((match[1] ?? '').toLowerCase())) spans.push({ kind: 'name', start: match.index, end: match.index + match[0].length })
83
+ }
84
+ for (const match of text.matchAll(TITLED_NAME)) {
85
+ spans.push({ kind: 'name', start: match.index, end: match.index + match[0].length })
86
+ }
87
+
88
+ const sorted = spans.sort((a, b) => a.start - b.start || b.end - a.end)
89
+ const kept: PersonalSpan[] = []
90
+ for (const span of sorted) {
91
+ const last = kept[kept.length - 1]
92
+ if (last === undefined || span.start >= last.end) kept.push(span)
93
+ }
94
+ return kept
95
+ }
96
+
97
+ /**
98
+ * Strips the Markdown a plain-text line would show raw: heading hashes, bold, underscore and code marks.
99
+ *
100
+ * @param line - One line of a message.
101
+ * @returns The line without those marks.
102
+ */
103
+ function plain(line: string): string {
104
+ return line.replace(/^#{1,6}\s+/, '').replace(/(\*\*|__|`)/g, '')
105
+ }
106
+
107
+ /**
108
+ * Reads the git user's name and email from the repository config, the names most likely to show up in replies.
109
+ *
110
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
111
+ * @returns The values that are set, `user.name` first, or `[]` when neither is set.
112
+ */
113
+ async function gitIdentity($: EngineInterface): Promise<string[]> {
114
+ const found: string[] = []
115
+ for (const key of ['user.name', 'user.email']) {
116
+ const run = await $.process.run(['git', 'config', '--get', key]).catch(() => undefined)
117
+ const value = run?.exitCode === 0 ? run.stdout.trim() : ''
118
+ if (value.length > 0) found.push(value)
119
+ }
120
+ return found
121
+ }
122
+
123
+ /**
124
+ * Registers this mod's `hide-personal-data` command, its session hook, and its assistant message hook.
125
+ *
126
+ * @param on - Hook registration function the engine passes in.
127
+ * @param options - Plugin options; `names` is a comma-separated list of names to always mask.
128
+ */
129
+ export const register: Register = (on, options) => {
130
+ const configured = typeof options.names === 'string'
131
+ ? options.names.split(',').map(name => name.trim()).filter(name => name.length > 0)
132
+ : []
133
+
134
+ on('session.start', async ($, e, next) => {
135
+ await $.command.register({
136
+ name: 'hide-personal-data',
137
+ description: 'Turn masking of names, emails and other personal data on or off',
138
+ argumentHint: 'on | off',
139
+ })
140
+ const identity = await gitIdentity($)
141
+ await update($, knownNames, () => [...configured, ...identity])
142
+ return next(e)
143
+ })
144
+
145
+ on('command.run', { command: 'hide-personal-data' }, async ($, e) => {
146
+ const wanted = e.args.trim()
147
+ if (wanted === 'on' || wanted === 'off') await update($, isOff, () => wanted === 'off')
148
+ const off = await read($, isOff)
149
+ return { text: `Personal data is ${off ? 'shown as written' : 'masked; hover a mask to read it'}.` }
150
+ })
151
+
152
+ on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
153
+ if (await read($, isOff)) return next(e)
154
+ const text = e.props.text
155
+ const names = await read($, knownNames)
156
+ if (findPersonalData(text, names).length === 0) return next(e)
157
+
158
+ const { Box, Text, Markdown } = $.ui.resolve(e)
159
+ const blocks: RenderChildren[] = []
160
+ let prose: string[] = []
161
+ /**
162
+ * Emits the prose lines collected so far as one Markdown block, then clears them.
163
+ */
164
+ const flush = () => {
165
+ if (prose.length > 0) blocks.push(<Markdown text={prose.join('\n')} />)
166
+ prose = []
167
+ }
168
+
169
+ text.split('\n').forEach((line, lineIndex) => {
170
+ const spans = findPersonalData(line, names)
171
+ if (spans.length === 0) {
172
+ prose.push(line)
173
+ return
174
+ }
175
+ flush()
176
+ const parts: RenderChildren[] = []
177
+ let at = 0
178
+ spans.forEach((span, spanIndex) => {
179
+ if (span.start > at) parts.push(<Text>{plain(line.slice(at, span.start))}</Text>)
180
+ const scope = `pd-${e.requestId}-${lineIndex}-${spanIndex}`
181
+ parts.push(<Text color={ACCENT}>▎</Text>)
182
+ parts.push(
183
+ <Text color={MASK} backgroundColor={MASK} hover={{ scope, color: 'text', backgroundColor: REVEAL }}>
184
+ {line.slice(span.start, span.end)}
185
+ </Text>,
186
+ )
187
+ at = span.end
188
+ })
189
+ if (at < line.length) parts.push(<Text>{plain(line.slice(at))}</Text>)
190
+ blocks.push(<Box flexDirection="row" flexWrap="wrap">{parts}</Box>)
191
+ })
192
+ flush()
193
+
194
+ return <Box flexDirection="column">{blocks}</Box>
195
+ })
196
+ }
@@ -0,0 +1,17 @@
1
+ // packages/g3n-mods/mods/hide-personal-data/types/index.d.ts
2
+
3
+ /** What kind of personal data a match is. */
4
+ export type PersonalKind = 'email' | 'phone' | 'iban' | 'card' | 'name'
5
+
6
+ /** One match in a text: its kind and its character range, `end` exclusive. */
7
+ export type PersonalSpan = {
8
+ kind: PersonalKind
9
+ start: number
10
+ end: number
11
+ }
12
+
13
+ declare module 'claude-code' {
14
+ interface PluginState {
15
+ 'hide-personal-data': { isOff: boolean; names: string[] }
16
+ }
17
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "mini-game",
3
+ "version": "0.1.0",
4
+ "description": "Minesweeper to play while Claude works.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,273 @@
1
+ // packages/g3n-mods/mods/mini-game/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { Game } from '../types'
6
+
7
+ const PANE = 'mini-game'
8
+ const TITLE = 'Minesweeper'
9
+ const game = atom({ plugin: 'mini-game', key: 'game' } as const, null)
10
+ const best = atom({ plugin: 'mini-game', key: 'best' } as const, null)
11
+
12
+ const SIZE = 9
13
+ const MINES = 10
14
+ const BEST_KEY = 'best-seconds'
15
+ /** How long a turn runs before the mod suggests a game, once per session. */
16
+ const HINT_AFTER_MS = 60_000
17
+ const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
18
+
19
+ /**
20
+ * Lists the cells around `cell` on a `size` × `size` board.
21
+ *
22
+ * @param cell - Cell number, counted row by row from `0`.
23
+ * @param size - Board width and height in cells.
24
+ * @returns The neighboring cell numbers, from one to eight of them.
25
+ */
26
+ export function neighbors(cell: number, size: number): number[] {
27
+ const row = Math.floor(cell / size)
28
+ const col = cell % size
29
+ const found: number[] = []
30
+ for (let dr = -1; dr <= 1; dr++) {
31
+ for (let dc = -1; dc <= 1; dc++) {
32
+ const r = row + dr
33
+ const c = col + dc
34
+ if ((dr !== 0 || dc !== 0) && r >= 0 && r < size && c >= 0 && c < size) found.push(r * size + c)
35
+ }
36
+ }
37
+ return found
38
+ }
39
+
40
+ /**
41
+ * Counts the mines in the cells around `cell`.
42
+ *
43
+ * @param board - Board to read, see `Game`.
44
+ * @param cell - Cell number to count around.
45
+ * @returns Number of neighboring mines, from `0` to `8`.
46
+ */
47
+ function adjacentMines(board: Game, cell: number): number {
48
+ return neighbors(cell, board.size).filter(n => board.mines.includes(n)).length
49
+ }
50
+
51
+ /**
52
+ * Picks random cells for the mines, leaving `safe` and the cells around it free.
53
+ *
54
+ * @param board - Board to place mines on, see `Game`.
55
+ * @param safe - Cell the first reveal hit, which must not be a mine.
56
+ * @returns Mine cell numbers, `board.mineCount` of them.
57
+ */
58
+ function placeMines(board: Game, safe: number): number[] {
59
+ const banned = new Set([safe, ...neighbors(safe, board.size)])
60
+ const free = Array.from({ length: board.size * board.size }, (_, i) => i).filter(i => !banned.has(i))
61
+ for (let i = free.length - 1; i > 0; i--) {
62
+ const j = Math.floor(Math.random() * (i + 1))
63
+ ;[free[i], free[j]] = [free[j] ?? 0, free[i] ?? 0]
64
+ }
65
+ return free.slice(0, board.mineCount)
66
+ }
67
+
68
+ /**
69
+ * @brief Reveals one cell: the first reveal places the mines, a zero opens its
70
+ * neighbours, a mine ends the game, and the last safe cell wins it.
71
+ *
72
+ * @param board - Board to reveal on, see `Game`.
73
+ * @param cell - Cell number to reveal.
74
+ * @param now - Milliseconds since the epoch, stamped as the start or end time.
75
+ * @returns The board after the reveal, or the same board when the cell cannot be revealed.
76
+ */
77
+ export function reveal(board: Game, cell: number, now: number): Game {
78
+ if (board.status === 'won' || board.status === 'lost') return board
79
+ if (board.revealed.includes(cell) || board.flagged.includes(cell)) return board
80
+
81
+ const mines = board.status === 'ready' ? placeMines(board, cell) : board.mines
82
+ const started: Game = board.status === 'ready' ? { ...board, mines, status: 'playing', startedAt: now } : board
83
+ if (mines.includes(cell)) return { ...started, revealed: [...started.revealed, cell], status: 'lost', endedAt: now }
84
+
85
+ const open = new Set(started.revealed)
86
+ const queue = [cell]
87
+ while (queue.length > 0) {
88
+ const next = queue.pop() ?? cell
89
+ if (open.has(next) || started.flagged.includes(next)) continue
90
+ open.add(next)
91
+ if (adjacentMines(started, next) === 0) queue.push(...neighbors(next, started.size).filter(n => !open.has(n)))
92
+ }
93
+
94
+ const revealed = [...open]
95
+ const isWon = revealed.length === started.size * started.size - started.mineCount
96
+ return isWon ? { ...started, revealed, status: 'won', endedAt: now } : { ...started, revealed }
97
+ }
98
+
99
+ /**
100
+ * Creates a board in the `ready` state, with no mines placed yet.
101
+ *
102
+ * @param now - Milliseconds since the epoch, stamped as the start time.
103
+ * @returns The new board, see `Game`.
104
+ */
105
+ function newGame(now: number): Game {
106
+ return { size: SIZE, mineCount: MINES, mines: [], revealed: [], flagged: [], status: 'ready', isFlagMode: false, startedAt: now }
107
+ }
108
+
109
+ /**
110
+ * Starts a new board and shows the pane.
111
+ *
112
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
113
+ */
114
+ async function startGame($: EngineInterface): Promise<void> {
115
+ const now = await $.clock.now()
116
+ await update($, game, () => newGame(now))
117
+ await $.ui.open({ id: PANE, title: TITLE })
118
+ }
119
+
120
+ /**
121
+ * Reveals or flags a cell, as the flag mode says, and records a best time on a win.
122
+ *
123
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
124
+ * @param cell - Cell number the player pressed.
125
+ */
126
+ async function pressCell($: EngineInterface, cell: number): Promise<void> {
127
+ const now = await $.clock.now()
128
+ const after = await update($, game, board => {
129
+ if (board === null) return board
130
+ if (!board.isFlagMode) return reveal(board, cell, now)
131
+ if (board.revealed.includes(cell) || board.status === 'won' || board.status === 'lost') return board
132
+ const flagged = board.flagged.includes(cell) ? board.flagged.filter(c => c !== cell) : [...board.flagged, cell]
133
+ return { ...board, flagged }
134
+ })
135
+ if (after?.status === 'won' && after.endedAt === now) {
136
+ const seconds = Math.round((now - after.startedAt) / 1000)
137
+ const previous = await read($, best)
138
+ if (previous === null || seconds < previous) {
139
+ await update($, best, () => seconds)
140
+ await $.store.set(BEST_KEY, seconds)
141
+ }
142
+ $.ui.toast(`Cleared in ${seconds}s${previous === null || seconds < previous ? ', a new best' : ''}`)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Switches the board between revealing and flagging cells.
148
+ *
149
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
150
+ */
151
+ async function toggleFlagMode($: EngineInterface): Promise<void> {
152
+ await update($, game, board => (board === null ? board : { ...board, isFlagMode: !board.isFlagMode }))
153
+ }
154
+
155
+ /**
156
+ * Redraws the timer once a second while a game is on.
157
+ *
158
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
159
+ */
160
+ async function tick($: EngineInterface): Promise<void> {
161
+ if ((await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
162
+ }
163
+
164
+ let hintTurn: string | undefined
165
+ let isHinted = false
166
+
167
+ /**
168
+ * Suggests a game once per session, when a turn is still running after a minute.
169
+ *
170
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
171
+ * @param turnId - Turn the hint was scheduled for; the hint is skipped when another turn is running.
172
+ */
173
+ async function hint($: EngineInterface, turnId: string): Promise<void> {
174
+ if (isHinted || hintTurn !== turnId) return
175
+ if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
176
+ isHinted = true
177
+ $.ui.toast('Waiting on Claude? /mini-game opens Minesweeper.')
178
+ }
179
+
180
+ /**
181
+ * Registers this mod's `mini-game` command, its session, turn and pane hooks.
182
+ *
183
+ * @param on - Hook registration function the engine passes in.
184
+ */
185
+ export const register: Register = on => {
186
+ on('session.start', async ($, e, next) => {
187
+ await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
188
+ const stored = await $.store.get(BEST_KEY)
189
+ if (typeof stored === 'number') await update($, best, () => stored)
190
+ $.clock.every(1000, () => void tick($))
191
+ return next(e)
192
+ })
193
+
194
+ on('command.run', { command: 'mini-game' }, async $ => {
195
+ const current = await read($, game)
196
+ if (current === null || current.status === 'won' || current.status === 'lost') await startGame($)
197
+ else await $.ui.open({ id: PANE, title: TITLE })
198
+ return { text: 'Minesweeper is open. Clear the board without hitting a mine.' }
199
+ })
200
+
201
+ on('turn.start', async ($, e, next) => {
202
+ hintTurn = e.turnId
203
+ const turnId = e.turnId
204
+ $.clock.after(HINT_AFTER_MS, () => void hint($, turnId))
205
+ return next(e)
206
+ })
207
+
208
+ on('turn.complete', async ($, e, next) => {
209
+ if (e.agentId === undefined) hintTurn = undefined
210
+ return next(e)
211
+ })
212
+
213
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
214
+ const { Box, Text, Button } = $.ui.resolve(e)
215
+ const board = await read($, game)
216
+ if (board === null) {
217
+ return <Button key="new" label="new game" variant="primary" onPress={() => startGame($)} />
218
+ }
219
+
220
+ const now = await $.clock.now()
221
+ const elapsed = board.status === 'ready' ? 0 : Math.round(((board.endedAt ?? now) - board.startedAt) / 1000)
222
+ const record = await read($, best)
223
+ const isOver = board.status === 'won' || board.status === 'lost'
224
+
225
+ /**
226
+ * Draws one cell: a number, a flag, a mine, or a button that reveals or flags it.
227
+ *
228
+ * @param cell - Cell number to draw.
229
+ * @returns The cell element.
230
+ */
231
+ const cellView = (cell: number) => {
232
+ const isRevealed = board.revealed.includes(cell)
233
+ const isMine = board.mines.includes(cell)
234
+ if (isRevealed || (isOver && isMine)) {
235
+ if (isMine) return <Text color="#e06c6c">✹ </Text>
236
+ const n = adjacentMines(board, cell)
237
+ return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : `${n} `}</Text>
238
+ }
239
+ const label = board.flagged.includes(cell) ? '⚑' : '■'
240
+ if (isOver) return <Text dimColor>{label} </Text>
241
+ return (
242
+ <Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
243
+ <Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
244
+ </Button>
245
+ )
246
+ }
247
+
248
+ const rows = Array.from({ length: board.size }, (_, r) => (
249
+ <Box flexDirection="row" columnGap={1}>
250
+ {Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
251
+ </Box>
252
+ ))
253
+
254
+ const status = board.status === 'won'
255
+ ? <Text color="#7cc47f" bold>Cleared in {elapsed}s</Text>
256
+ : board.status === 'lost'
257
+ ? <Text color="#e06c6c" bold>Boom. {board.revealed.length} cells cleared</Text>
258
+ : <Text dimColor>💣 {board.mineCount - board.flagged.length} · ⏱ {elapsed}s{record === null ? '' : ` · best ${record}s`}</Text>
259
+
260
+ return (
261
+ <Box flexDirection="column" gap={1}>
262
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
263
+ {status}
264
+ <Box flexDirection="row" columnGap={1}>
265
+ {isOver ? '' : <Button key="flag" label={board.isFlagMode ? '⚑ flagging' : '⚑ flag'} variant={board.isFlagMode ? 'primary' : 'secondary'} onPress={() => toggleFlagMode($)} />}
266
+ <Button key="new" label="new game" onPress={() => startGame($)} />
267
+ </Box>
268
+ </Box>
269
+ <Box flexDirection="column">{rows}</Box>
270
+ </Box>
271
+ )
272
+ })
273
+ }
@@ -0,0 +1,21 @@
1
+ // packages/g3n-mods/mods/mini-game/types/index.d.ts
2
+
3
+ /** A Minesweeper board; cells are numbered row by row from 0. Times are milliseconds since the epoch. */
4
+ export type Game = {
5
+ size: number
6
+ mineCount: number
7
+ /** Empty until the first reveal, which never hits a mine */
8
+ mines: number[]
9
+ revealed: number[]
10
+ flagged: number[]
11
+ status: 'ready' | 'playing' | 'won' | 'lost'
12
+ isFlagMode: boolean
13
+ startedAt: number
14
+ endedAt?: number
15
+ }
16
+
17
+ declare module 'claude-code' {
18
+ interface PluginState {
19
+ 'mini-game': { game: Game | null; best: number | null }
20
+ }
21
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "tool-timeline",
3
+ "version": "0.1.0",
4
+ "description": "Each tool call with its duration; a denied call is struck through next to the call that replaced it.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }