Live WHOOP heart rate in the status line; risky Bash commands are blocked while it is over your limit

Claude Code won't touch prod while your heart is racing.
This mod puts your live WHOOP heart rate in the status line (♥ 87) and blocks risky Bash commands while it is at or over your limit (❤️ 112 · deploys locked). Deploys, force pushes, rm -rf, publishes, PR merges, terraform apply, destructive SQL: the same list as the whoopgate CLI. To unlock, run whoopgate breathe and stay under 92 bpm for 20 seconds. That opens a 2 minute window.
It is the in-process version of whoopgate's settings hooks and status line. You still run the whoopgate daemon, which reads the band over Bluetooth.
whoopgate daemon # WHOOP app > Heart Rate Broadcast on
claude --plugin-dir /path/to/claude-mods/whoopgate
Or add the repo as a marketplace and install it with /plugin. Use the mod or whoopgate install, not both, or every risky command is checked twice.
♥ 87, ❤️ 112 · deploys locked, ♥ 112 · unlocked 1:43, ♡ whoop offline. Refreshed every 2 s./pulse shows the current bpm, the threshold and the unlock state.| Option | Default | What it does |
|---|---|---|
threshold | 100 | bpm at or above which risky commands are blocked |
The mod also follows ~/.whoopgate/config.json for calmBpm and unlockSeconds (what the deny message tells you) and extraPatterns (your own risky regexes), and honours whoopgate pause.
sim.sh writes a simulated heart rate to ~/.whoopgate/hr.json (with "device": "SIMULATED"). Stop the real daemon first.
./sim.sh 120 # hold around 120 bpm
./sim.sh ramp # climb from 80 to 125
./sim.sh unlock 60 # act as if `whoopgate breathe` worked
A $.clock.every timer reads ~/.whoopgate/hr.json for the status line, and a tool.call hook on Bash reads it again and returns { deny } for a risky command while the reading is over the limit. A reading older than 30 s counts as unknown and never blocks.
hooks/register.ts 178 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { BARE_PUSH, classify, shorten } from './risky'
4
5// The whoopgate daemon streams the WHOOP band's heart rate into
6// ~/.whoopgate/hr.json once a second; this mod only reads it.
7const STALE_SECONDS = 30
8const TICK_MS = 2000
9
10type Heart = { bpm: number; ageSeconds: number; device: string }
11
12type Gate = {
13 heart: Heart | undefined
14 threshold: number
15 calmBpm: number
16 unlockSeconds: number
17 unlockedFor: number // seconds left on a `whoopgate breathe` unlock, 0 when none
18 pausedFor: number // seconds left on a `whoopgate pause`, 0 when none
19 extra: RegExp[]
20}
21
22type Verdict = 'unknown' | 'paused' | 'unlocked' | 'locked' | 'calm'
23
24const verdictOf = (gate: Gate): Verdict => {
25 if (gate.heart === undefined) return 'unknown'
26 if (gate.pausedFor > 0) return 'paused'
27 if (gate.heart.bpm < gate.threshold) return 'calm'
28
29 return gate.unlockedFor > 0 ? 'unlocked' : 'locked'
30}
31
32const readJson = async ($: EngineInterface, path: string): Promise<Record<string, unknown>> => {
33 try {
34 const parsed: unknown = JSON.parse(await $.fs.read(path))
35 return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
36 } catch {
37 return {}
38 }
39}
40
41const num = (value: unknown, fallback: number): number =>
42 typeof value === 'number' && Number.isFinite(value) ? value : fallback
43
44const toRegExps = (patterns: unknown): RegExp[] =>
45 (Array.isArray(patterns) ? patterns : []).flatMap(pattern => {
46 try {
47 return typeof pattern === 'string' ? [new RegExp(pattern)] : []
48 } catch {
49 return []
50 }
51 })
52
53const readGate = async ($: EngineInterface, threshold: number): Promise<Gate> => {
54 const dir = `${(await $.env.get('HOME')) ?? ''}/.whoopgate`
55 const nowSeconds = (await $.clock.now()) / 1000
56 const [hr, unlock, pause, config] = await Promise.all(
57 ['hr.json', 'unlock.json', 'pause.json', 'config.json'].map(name => readJson($, `${dir}/${name}`)),
58 )
59 const ageSeconds = nowSeconds - num(hr?.ts, -Infinity)
60 const bpm = num(hr?.bpm, NaN)
61 const isFresh = Math.abs(ageSeconds) <= STALE_SECONDS && bpm > 0
62 const left = (file: Record<string, unknown> | undefined) =>
63 Math.max(0, Math.round(num(file?.until, 0) - nowSeconds))
64
65 return {
66 heart: isFresh
67 ? { bpm: Math.round(bpm), ageSeconds, device: typeof hr?.device === 'string' ? hr.device : 'WHOOP' }
68 : undefined,
69 threshold,
70 calmBpm: num(config?.calmBpm, 92),
71 unlockSeconds: num(config?.unlockSeconds, 120),
72 unlockedFor: left(unlock),
73 pausedFor: left(pause),
74 extra: toRegExps(config?.extraPatterns),
75 }
76}
77
78const clockOf = (seconds: number) =>
79 `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
80
81const statusOf = (gate: Gate): string => {
82 const bpm = gate.heart?.bpm
83 switch (verdictOf(gate)) {
84 case 'unknown':
85 return '♡ whoop offline'
86 case 'paused':
87 return `♥ ${bpm} · gate paused`
88 case 'unlocked':
89 return `♥ ${bpm} · unlocked ${clockOf(gate.unlockedFor)}`
90 case 'locked':
91 return `❤️ ${bpm} · deploys locked`
92 case 'calm':
93 return `♥ ${bpm}`
94 }
95}
96
97const denyOf = (gate: Gate, label: string, command: string): string =>
98 [
99 `whoopgate blocked this ${label}: \`${shorten(command)}\``,
100 `The user's live WHOOP heart rate is ${gate.heart?.bpm} bpm, at or over their limit of ${gate.threshold}. Their rule: no risky commands while their heart is racing.`,
101 `To unlock, they run \`whoopgate breathe\` in their own terminal and stay under ${gate.calmBpm} bpm for 20 seconds, which opens a ${Math.round(gate.unlockSeconds / 60)} minute window.`,
102 "Do not retry this command or work around it with a different command. Tell the user it was blocked and why, then continue with anything that doesn't need it.",
103 ].join('\n')
104
105const pulseOf = (gate: Gate): string => {
106 const { heart } = gate
107 if (heart === undefined) {
108 return [
109 `No live heart rate: ~/.whoopgate/hr.json is missing or older than ${STALE_SECONDS} s.`,
110 'Start `whoopgate daemon` with the band nearby (or the mod\'s sim.sh for a simulated heart).',
111 'Risky commands are allowed while the heart rate is unknown.',
112 ].join('\n')
113 }
114 const verdict = verdictOf(gate)
115 const gateLine = {
116 paused: `paused for ${clockOf(gate.pausedFor)} (whoopgate pause)`,
117 unlocked: 'over the limit, but unlocked',
118 locked: 'risky commands BLOCKED',
119 calm: 'risky commands allowed',
120 unknown: '',
121 }[verdict]
122 const unlockLine =
123 gate.unlockedFor > 0
124 ? `unlocked for ${clockOf(gate.unlockedFor)} more`
125 : `no unlock window. \`whoopgate breathe\`: ${gate.calmBpm} bpm or under for 20 s opens one`
126
127 return [
128 `${verdict === 'locked' ? '❤️' : '♥'} ${heart.bpm} bpm from ${heart.device} (${Math.max(0, Math.round(heart.ageSeconds))} s ago)`,
129 `threshold ${gate.threshold} bpm · ${gateLine}`,
130 unlockLine,
131 ].join('\n')
132}
133
134const currentBranch = async ($: EngineInterface): Promise<string | undefined> => {
135 try {
136 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
137 timeoutMs: 3000,
138 })
139 return exitCode === 0 ? stdout.trim() : undefined
140 } catch {
141 return undefined
142 }
143}
144
145const refresh = async ($: EngineInterface, threshold: number) => {
146 $.ui.status(statusOf(await readGate($, threshold)))
147}
148
149export const register: Register = (on, options) => {
150 const threshold = num(options.threshold, 100)
151
152 on('session.start', async ($, e, next) => {
153 await $.command.register({
154 name: 'pulse',
155 description: 'Show your live WHOOP heart rate, the threshold and the unlock state',
156 })
157 await refresh($, threshold)
158 $.clock.every(TICK_MS, () => void refresh($, threshold).catch(() => undefined))
159
160 return next(e)
161 })
162
163 on('command.run', { command: 'pulse' }, async $ => ({ text: pulseOf(await readGate($, threshold)) }))
164
165 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
166 const gate = await readGate($, threshold)
167 if (verdictOf(gate) !== 'locked') return next(e)
168 const branch = BARE_PUSH.test(e.command) ? await currentBranch($) : undefined
169 const label = classify(e.command, gate.extra, branch)
170 if (label === undefined) return next(e)
171
172 $.ui.toast(`❤️ ${gate.heart?.bpm} bpm · blocked ${label}`)
173 $.ui.status(statusOf(gate))
174
175 return { deny: denyOf(gate, label, e.command) }
176 })
177}
178hooks/risky.ts 54 lines1// Which shell commands count as risky. A port of the classifier in
2// whoopgate's Sources/main.swift, so the mod and the CLI agree.
3
4export const RISKY: readonly (readonly [label: string, pattern: RegExp])[] = [
5 ['force push', /\bgit\s+push\b[^|;&]*\s(--force|--force-with-lease|-f)\b/],
6 ['push to main', /\bgit\s+push\b[^|;&]*\s(\S+\s+)?(main|master)\b/],
7 ['hard reset', /\bgit\s+reset\b[^|;&]*--hard\b/],
8 ['git clean', /\bgit\s+clean\s+-[a-zA-Z]*f/],
9 ['branch delete', /\bgit\s+branch\s+(-D|--delete\s+--force)\b/],
10 [
11 'rm -rf',
12 /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-r\s+-f|-f\s+-r|--recursive\s+--force|--force\s+--recursive)\b/,
13 ],
14 [
15 'prod deploy',
16 /\b(wrangler\s+(deploy|publish)|vercel\b[^|;&]*--prod|netlify\s+deploy\b[^|;&]*--prod|fly(ctl)?\s+deploy|railway\s+up|firebase\s+deploy|serverless\s+deploy|sls\s+deploy|cdk\s+deploy|eas\s+submit)\b/,
17 ],
18 ['publish', /\b(npm|pnpm|yarn|bun)\s+publish\b|\bcargo\s+publish\b|\btwine\s+upload\b/],
19 [
20 'infra change',
21 /\bterraform\s+(apply|destroy)\b|\bkubectl\s+(delete|apply|drain)\b|\bhelm\s+(upgrade|uninstall)\b/,
22 ],
23 ['destructive SQL', /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i],
24 ['destructive SQL', /\b(psql|mysql|mariadb|sqlite3)\b.*\btruncate\s+\w/is],
25 ['PR merge', /\bgh\s+pr\s+merge\b/],
26 ['release', /\bgh\s+release\s+create\b/],
27]
28
29/** A bare `git push`, risky only while the checkout sits on main or master. */
30export const BARE_PUSH = /\bgit\s+push\b/
31
32/**
33 * The label of the first rule `command` breaks, or undefined when it is safe.
34 * `extra` are the person's own patterns; `branch` the current git branch.
35 */
36export const classify = (
37 command: string,
38 extra: readonly RegExp[] = [],
39 branch?: string,
40): string | undefined => {
41 for (const [label, pattern] of RISKY) if (pattern.test(command)) return label
42 for (const pattern of extra) if (pattern.test(command)) return 'custom rule'
43 const isOnMain = branch === 'main' || branch === 'master'
44
45 return isOnMain && BARE_PUSH.test(command) ? 'push to main' : undefined
46}
47
48/** One line of a command, cut to fit a message. */
49export const shorten = (command: string, max = 80): string => {
50 const line = command.trim().replace(/\s+/g, ' ')
51
52 return line.length > max ? `${line.slice(0, max - 3)}...` : line
53}
54