@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,151 @@
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
+ }
@@ -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"] }
@@ -0,0 +1,184 @@
1
+ // packages/g3n-mods/mods/focus-mode/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { FocusBlock } from '../types'
6
+
7
+ const PANE = 'focus-mode'
8
+ const TITLE = 'Focus'
9
+ const block = atom({ plugin: 'focus-mode', key: 'block' } as const, null)
10
+ const finished = atom({ plugin: 'focus-mode', key: 'finished' } as const, 0)
11
+
12
+ const DEFAULT_MINUTES = 25
13
+ const MAX_SQUARES = 30
14
+ const FILLED = '#e6b450'
15
+ const EMPTY = '#3a3f4b'
16
+
17
+ /**
18
+ * Formats remaining time as `mm:ss`, or `h:mm:ss` from an hour up.
19
+ *
20
+ * @param ms - Milliseconds left. Rounded up to whole seconds; negative values count as `0`.
21
+ * @returns Clock text, e.g. `24:59`.
22
+ */
23
+ export function clock(ms: number): string {
24
+ const total = Math.max(0, Math.ceil(ms / 1000))
25
+ const h = Math.floor(total / 3600)
26
+ const m = Math.floor((total % 3600) / 60)
27
+ const s = total % 60
28
+ const mm = String(m).padStart(2, '0')
29
+ const ss = String(s).padStart(2, '0')
30
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
31
+ }
32
+
33
+ /**
34
+ * Formats a wall-clock time as `3:10 pm`.
35
+ *
36
+ * @param epochMs - Time in milliseconds since the epoch, shown in local time.
37
+ * @returns 12-hour clock text.
38
+ */
39
+ function wallTime(epochMs: number): string {
40
+ const date = new Date(epochMs)
41
+ const hours = date.getHours()
42
+ const minutes = String(date.getMinutes()).padStart(2, '0')
43
+ return `${hours % 12 === 0 ? 12 : hours % 12}:${minutes} ${hours < 12 ? 'am' : 'pm'}`
44
+ }
45
+
46
+ /**
47
+ * Draws a row of squares as one SVG, lighting the first `filled` of them.
48
+ *
49
+ * @param count - Number of squares.
50
+ * @param filled - Number of lit squares, counted from the left.
51
+ * @returns The SVG markup as a string.
52
+ */
53
+ function squaresSvg(count: number, filled: number): string {
54
+ const size = 10
55
+ const gap = 3
56
+ const width = count * (size + gap) - gap
57
+ const rects = Array.from({ length: count }, (_, i) =>
58
+ `<rect x="${i * (size + gap)}" y="0" width="${size}" height="${size}" rx="2" fill="${i < filled ? FILLED : EMPTY}"/>`)
59
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${rects.join('')}</svg>`
60
+ }
61
+
62
+ /**
63
+ * Starts a block of `minutes`, replacing one already running, and shows the pane.
64
+ *
65
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
66
+ * @param minutes - Length of the block in minutes.
67
+ */
68
+ async function startFocus($: EngineInterface, minutes: number): Promise<void> {
69
+ const startedAt = await $.clock.now()
70
+ await update($, block, () => ({ startedAt, endsAt: startedAt + minutes * 60_000, minutes }))
71
+ await $.ui.open({ id: PANE, title: TITLE })
72
+ }
73
+
74
+ /**
75
+ * Ends the running block, and counts it as done when it ran its full time.
76
+ *
77
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
78
+ * @param isDone - `true` when the block ran out, `false` when the user ended it early.
79
+ */
80
+ async function endFocus($: EngineInterface, isDone: boolean): Promise<void> {
81
+ const current = await read($, block)
82
+ if (current === null) return
83
+ await update($, block, () => null)
84
+ $.ui.status(undefined)
85
+ if (isDone) {
86
+ await update($, finished, n => n + 1)
87
+ $.ui.toast(`Focus block done: ${current.minutes} min`)
88
+ await $.ui.notify(`${current.minutes} minutes of focus done. Take a break.`, { title: 'Focus block done' }).catch(() => undefined)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Moves the running block on by one second: ends it when its time is up, else updates the countdown.
94
+ *
95
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
96
+ */
97
+ async function tick($: EngineInterface): Promise<void> {
98
+ const current = await read($, block)
99
+ if (current === null) return
100
+ const now = await $.clock.now()
101
+ if (now >= current.endsAt) {
102
+ await endFocus($, true)
103
+ return
104
+ }
105
+ $.ui.status(`focus ${clock(current.endsAt - now)}`)
106
+ $.ui.invalidate('ui.render')
107
+ }
108
+
109
+ /**
110
+ * Registers this mod's `focus-mode` command, its session hook, its tick, and its pane.
111
+ *
112
+ * @param on - Hook registration function the engine passes in.
113
+ * @param options - Plugin options; `minutes` sets the default block length.
114
+ */
115
+ export const register: Register = (on, options) => {
116
+ const configured = Number(options.minutes ?? DEFAULT_MINUTES)
117
+ const defaultMinutes = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MINUTES
118
+
119
+ on('session.start', async ($, e, next) => {
120
+ await $.command.register({
121
+ name: 'focus-mode',
122
+ description: `Start a focus block (${defaultMinutes} min by default), or end the running one`,
123
+ argumentHint: '[minutes] | end',
124
+ })
125
+ $.clock.every(1000, () => void tick($))
126
+ return next(e)
127
+ })
128
+
129
+ on('command.run', { command: 'focus-mode' }, async ($, e) => {
130
+ const arg = e.args.trim()
131
+ if (arg === 'end' || arg === 'stop') {
132
+ await endFocus($, false)
133
+ return { text: 'Focus block ended.' }
134
+ }
135
+ const minutes = arg === '' ? defaultMinutes : Number(arg)
136
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 600) {
137
+ return { text: `"${arg}" is not a number of minutes. Use /focus-mode 25, or /focus-mode end.` }
138
+ }
139
+ await startFocus($, minutes)
140
+ return { text: `Focus block started: ${minutes} min.` }
141
+ })
142
+
143
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144
+ const { Box, Text, Button } = $.ui.resolve(e)
145
+ const current = await read($, block)
146
+
147
+ if (current === null) {
148
+ const done = await read($, finished)
149
+ return (
150
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
151
+ <Text dimColor>{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
152
+ <Button key="start" label={`start ${defaultMinutes} min`} variant="primary" onPress={() => startFocus($, defaultMinutes)} />
153
+ </Box>
154
+ )
155
+ }
156
+
157
+ const now = await $.clock.now()
158
+ const remaining = current.endsAt - now
159
+ const count = Math.min(MAX_SQUARES, Math.max(1, Math.round(current.minutes)))
160
+ const filled = Math.ceil((remaining / (current.endsAt - current.startedAt)) * count)
161
+ const squares = e.surface === 'terminal'
162
+ ? (
163
+ <Text>
164
+ <Text color={FILLED}>{'■'.repeat(filled)}</Text>
165
+ <Text color={EMPTY}>{'■'.repeat(count - filled)}</Text>
166
+ </Text>
167
+ )
168
+ : (() => {
169
+ const { Svg } = $.ui.resolve(e)
170
+ return <Svg source={squaresSvg(count, filled)} alt={`${clock(remaining)} left`} width={count * 13 - 3} height={10} />
171
+ })()
172
+
173
+ return (
174
+ <Box flexDirection="row" alignItems="center" columnGap={3}>
175
+ <Text bold>{clock(remaining)}</Text>
176
+ <Box flexDirection="column" flexGrow={1} gap={0}>
177
+ {squares}
178
+ <Text dimColor>focus block · {current.minutes} min · ends {wallTime(current.endsAt)}</Text>
179
+ </Box>
180
+ <Button key="end" label="end focus" onPress={() => endFocus($, false)} />
181
+ </Box>
182
+ )
183
+ })
184
+ }
@@ -0,0 +1,14 @@
1
+ // packages/g3n-mods/mods/focus-mode/types/index.d.ts
2
+
3
+ /** A running focus block; times are milliseconds since the epoch. */
4
+ export type FocusBlock = {
5
+ startedAt: number
6
+ endsAt: number
7
+ minutes: number
8
+ }
9
+
10
+ declare module 'claude-code' {
11
+ interface PluginState {
12
+ 'focus-mode': { block: FocusBlock | null; finished: number }
13
+ }
14
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "handoff",
3
+ "version": "0.1.0",
4
+ "description": "Writes a handoff with the handoff skill, clears the session, and starts the fresh one with your next prompt.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,156 @@
1
+ // packages/g3n-mods/mods/handoff/hooks/register.tsx
2
+ import { atom, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { PendingHandoff } from '../types'
6
+
7
+ const pending = atom({ plugin: 'handoff', key: 'pending' } as const, null)
8
+
9
+ const STORE_KEY = 'pending'
10
+ const SKILL_PATH = '.claude/skills/handoff/SKILL.md'
11
+ /** A handoff older than this is dropped instead of attached. */
12
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000
13
+
14
+ /** The handoff skill's instructions, used when no `handoff` skill is installed. */
15
+ const FALLBACK_SKILL = [
16
+ 'Write a handoff document summarising the current conversation so a fresh agent can continue the work.',
17
+ 'Include a "suggested skills" section, which suggests skills that the agent should invoke.',
18
+ 'Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.',
19
+ 'Redact any sensitive information, such as API keys, passwords, or personally identifiable information.',
20
+ 'If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.',
21
+ ].join('\n\n')
22
+
23
+ /**
24
+ * Removes the frontmatter block from a Markdown skill file.
25
+ *
26
+ * @param text - Full text of a `SKILL.md` file.
27
+ * @returns The body, trimmed.
28
+ */
29
+ function withoutFrontmatter(text: string): string {
30
+ return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
31
+ }
32
+
33
+ /**
34
+ * Tells whether a stored value has the shape of a `PendingHandoff`.
35
+ *
36
+ * @param value - Value read from the store, of unknown shape.
37
+ * @returns `true` when `text` and `path` are strings and `createdAt` is a number.
38
+ */
39
+ function isPendingHandoff(value: unknown): value is PendingHandoff {
40
+ return typeof value === 'object' && value !== null
41
+ && 'text' in value && typeof value.text === 'string'
42
+ && 'path' in value && typeof value.path === 'string'
43
+ && 'createdAt' in value && typeof value.createdAt === 'number'
44
+ }
45
+
46
+ /**
47
+ * Loads the `handoff` skill's instructions: the project's copy, else the user's, else the built-in copy.
48
+ *
49
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
50
+ * @returns The instructions without their frontmatter.
51
+ */
52
+ async function loadSkill($: EngineInterface): Promise<string> {
53
+ const root = await $.session.root()
54
+ const home = await $.env.get('HOME')
55
+ for (const path of [`${root}/${SKILL_PATH}`, home === undefined ? '' : `${home}/${SKILL_PATH}`]) {
56
+ if (path !== '' && (await $.fs.exists(path))) return withoutFrontmatter(await $.fs.read(path))
57
+ }
58
+ return FALLBACK_SKILL
59
+ }
60
+
61
+ /**
62
+ * Builds the path of a handoff document in the OS temporary folder, named by the time it was written.
63
+ *
64
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
65
+ * @param now - Time the handoff was written, in milliseconds since the epoch.
66
+ * @returns Absolute path, e.g. `/tmp/claude-handoff-2026-10-11T09-30-00.md`.
67
+ */
68
+ async function documentPath($: EngineInterface, now: number): Promise<string> {
69
+ const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
70
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-').slice(0, 19)
71
+ return `${tmp}/claude-handoff-${stamp}.md`
72
+ }
73
+
74
+ /**
75
+ * Runs /clear, after the command that asked for it has answered. Shows a toast when /clear cannot run.
76
+ *
77
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
78
+ */
79
+ async function clearSession($: EngineInterface): Promise<void> {
80
+ await $.command.run({ command: 'clear' }).catch(() => $.ui.toast('Run /clear to start the fresh session; the handoff waits for your next prompt.'))
81
+ }
82
+
83
+ /**
84
+ * Takes the handoff waiting for the next prompt, and removes it from the store either way.
85
+ *
86
+ * @param $ - Engine handle the hook received, see `EngineInterface`.
87
+ * @returns The handoff, or `undefined` when none waits or it is older than 24 hours.
88
+ */
89
+ async function takePending($: EngineInterface): Promise<PendingHandoff | undefined> {
90
+ const stored = await $.store.get(STORE_KEY)
91
+ if (!isPendingHandoff(stored)) return undefined
92
+ await $.store.delete(STORE_KEY)
93
+ await update($, pending, () => null)
94
+ $.ui.status(undefined)
95
+ const now = await $.clock.now()
96
+ return now - stored.createdAt > MAX_AGE_MS ? undefined : stored
97
+ }
98
+
99
+ /**
100
+ * Registers this mod's `handoff-new` command, its session hook, and its prompt hook.
101
+ *
102
+ * @param on - Hook registration function the engine passes in.
103
+ */
104
+ export const register: Register = on => {
105
+ on('session.start', async ($, e, next) => {
106
+ await $.command.register({
107
+ name: 'handoff-new',
108
+ description: 'Write a handoff, clear the session, and start the fresh one with your next prompt',
109
+ argumentHint: 'what the next session is for',
110
+ })
111
+ const stored = await $.store.get(STORE_KEY)
112
+ if (isPendingHandoff(stored)) {
113
+ await update($, pending, () => stored)
114
+ $.ui.status('handoff ready: your next prompt starts with it')
115
+ }
116
+ return next(e)
117
+ })
118
+
119
+ on('command.run', { command: 'handoff-new' }, async ($, e) => {
120
+ const skill = await loadSkill($)
121
+ const focus = e.args.trim()
122
+ const prompt = [
123
+ 'Write the handoff document these instructions describe, for the conversation so far.',
124
+ 'You cannot use tools. Answer with the document as Markdown and nothing else; it is saved for you.',
125
+ focus.length > 0 ? `The next session will be used for: ${focus}` : '',
126
+ '<instructions>',
127
+ skill,
128
+ '</instructions>',
129
+ ].filter(line => line.length > 0).join('\n\n')
130
+
131
+ $.ui.toast('Writing the handoff…')
132
+ const written = await $.model.fork({ prompt })
133
+ if (!written.isAnswered) {
134
+ return { text: `The handoff was not written (${written.reason}). The conversation is unchanged.`, exitCode: 1 }
135
+ }
136
+
137
+ const now = await $.clock.now()
138
+ const path = await documentPath($, now)
139
+ await $.fs.write(path, written.text)
140
+ const handoff: PendingHandoff = { text: written.text, path, createdAt: now }
141
+ await $.store.set(STORE_KEY, handoff)
142
+ await update($, pending, () => handoff)
143
+ $.ui.status('handoff ready: your next prompt starts with it')
144
+ $.clock.after(0, () => void clearSession($))
145
+ return { text: `Handoff saved to ${path}. The conversation clears now; your next prompt starts the fresh session with it.` }
146
+ })
147
+
148
+ on('prompt.submit', async ($, e, next) => {
149
+ if (e.text.trimStart().startsWith('/')) return next(e)
150
+ const handoff = await takePending($)
151
+ if (handoff === undefined) return next(e)
152
+ $.ui.toast('Handoff attached to this prompt')
153
+ const context = `Handoff from the previous session, saved at ${handoff.path}:\n\n${handoff.text}`
154
+ return next({ ...e, context: [...(e.context ?? []), context] })
155
+ }).catch(($, e, next) => next(e))
156
+ }
@@ -0,0 +1,15 @@
1
+ // packages/g3n-mods/mods/handoff/types/index.d.ts
2
+
3
+ /** A handoff written and waiting for the next prompt; `createdAt` is milliseconds since the epoch. */
4
+ export type PendingHandoff = {
5
+ text: string
6
+ /** Absolute path the document was saved to */
7
+ path: string
8
+ createdAt: number
9
+ }
10
+
11
+ declare module 'claude-code' {
12
+ interface PluginState {
13
+ handoff: { pending: PendingHandoff | null }
14
+ }
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "hide-personal-data",
3
+ "version": "0.1.0",
4
+ "description": "Masks names, emails and other personal data in replies; hover to show them.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts",
9
+ "userConfig": {
10
+ "names": {
11
+ "type": "string",
12
+ "title": "Names",
13
+ "default": "",
14
+ "description": "Extra names to mask, separated by commas"
15
+ }
16
+ }
17
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }