Slap your MacBook and Claude Code feels it: a face, a yelp, and optionally a stopped turn

Your MacBook has an accelerometer. Claude Code has plugins. You have feelings. Now they can finally meet.
Slap your MacBook (or the desk it sits on) and Claude Code feels it: a face pops up above the prompt, the laptop yelps back in Japanese, and a little counter keeps score of how your day is going. Optionally, Claude itself gets the message — and a hard enough slap stops it mid-turn.
<img src="plugin/assets/faces/sakura/level_1.png" width="96" alt="Sakura, level 1"> <img src="plugin/assets/faces/sakura/level_2.png" width="96" alt="Sakura, level 2"> <img src="plugin/assets/faces/sakura/level_3.png" width="96" alt="Sakura, level 3"> <img src="plugin/assets/faces/sakura/level_4.png" width="96" alt="Sakura, level 4"> <img src="plugin/assets/faces/sakura/level_5.png" width="96" alt="Sakura, level 5"> <img src="plugin/assets/faces/natsu/level_1.png" width="96" alt="Natsu, level 1"> <img src="plugin/assets/faces/natsu/level_2.png" width="96" alt="Natsu, level 2"> <img src="plugin/assets/faces/natsu/level_3.png" width="96" alt="Natsu, level 3"> <img src="plugin/assets/faces/natsu/level_4.png" width="96" alt="Natsu, level 4"> <img src="plugin/assets/faces/natsu/level_5.png" width="96" alt="Natsu, level 5"> <img src="plugin/assets/faces/aki/level_1.png" width="96" alt="Aki, level 1"> <img src="plugin/assets/faces/aki/level_2.png" width="96" alt="Aki, level 2"> <img src="plugin/assets/faces/aki/level_3.png" width="96" alt="Aki, level 3"> <img src="plugin/assets/faces/aki/level_4.png" width="96" alt="Aki, level 4"> <img src="plugin/assets/faces/aki/level_5.png" width="96" alt="Aki, level 5">
| Level | Hit (at 0.05 g) | Sakura says | Natsu says | Aki says | What probably happened |
|---|---|---|---|---|---|
| 1 | 0.050–0.106 g | んっ! | えっ! | ひゃっ! | A tap. Passive-aggressive at most. |
| 2 | 0.106–0.224 g | あっ! | うっ! | あれっ! | The tests failed again. |
| 3 | 0.224–0.473 g | いたっ! | いてっ! | いたぁ! | Claude "simplified" your code. |
| 4 | 0.473–1 g | きゃっ! | やっ! | いやっ! | Claude deleted the tests to make them pass. |
| 5 | 1 g and up | あぁっ…! | うわぁっ! | きゃあっ! | Production. |
The g ranges are for the default sensitivity, 0.05 g. The scale moves with yours (/slaps calibrate, or "Slap sensitivity" in /config): level 1 starts at your sensitivity, level 5 at 1 g, and the levels between are spread evenly. At 0.15 g, for example, they start at about 0.15, 0.24, 0.39, 0.62 and 1 g.
Two exceptions. Below 0.05 g the scale stays as it is at 0.05 g: softer taps count, as level 1, but a moderate slap doesn't turn into a level 4. Above 0.25 g, level 5 starts at 4 times your sensitivity instead of 1 g, so the levels don't bunch up.
Keep slapping, less than a second apart, and every tap counts, however soft: each is one level above the one before, or its own level if it hit harder, and the level never drops until you pause. Five soft taps make her scream. A harder slap cuts her off with its own yelp; one at the same level does too, but stays quiet if her last yelp is under 0.4 s old, so drumming doesn't stutter. The face says how many came in a row, and a combo puts up one toast as it starts and one as it ends, saying how far it got. The score (/slaps, the status line) still keeps what the sensor read.
Taps can come as fast as about 7 a second. A hard slap leaves the laptop ringing, so the next tap counts once that has died down, usually within a fraction of a second.
sudo there; older releases may want root (untested).xcode-select --install). The plugin builds its little sensor reader from Swift source on first run, so nothing precompiled ships in the repo.Two commands, no cloning:
claude plugin marketplace add slima4/spank-claude
claude plugin install spank@spank-claude
(or the same from inside Claude Code: /plugin marketplace add slima4/spank-claude, then /plugin install spank@spank-claude.)
Start Claude Code. The first session builds the sensor reader (the status line says spank: building the sensor reader, about 20 seconds), then shows spank: armed (0.05g). Go on. Slap it.
Updates: claude plugin update spank@spank-claude, then restart Claude Code. Each new version builds its sensor reader again on its first start. Uninstall: claude plugin uninstall spank@spank-claude.
| Command | Does |
|---|---|
/slaps | Your score: this session, all time, and the last hit. |
/slaps who | Lists the face series; the current one is marked. |
/slaps who natsu | Natsu gets slapped now (any series name works). |
/slaps calibrate | Type 6 s (no Enter), knock 3 times; it picks the sensitivity. |
/slaps mute | Silences her. The faces still judge you. |
/slaps unmute | She's back. |
/slaps claude on | Slaps reach Claude; a level 4+ slap or combo stops its turn. |
/slaps claude off | Claude stays blissfully unaware (the default). |
/slaps image | Checks whether your terminal can show real pictures. |
With /slaps claude on, every slap becomes a quiet note in the conversation: "The user just physically slapped their laptop 2 times (accelerometer; strongest hit level 3 of 5, 0.31g). Take it as nonverbal frustration…" Claude reads it on its next step, acknowledges it, and reconsiders what it was doing. Slaps that keep coming are told as one note once you pause, or 4 seconds after the first, whichever comes sooner. Slaps while it's idle are saved up and delivered as one note with your next message, so it gets the whole story at once.
A level 4+ slap while Claude is working stops the turn on the spot (the level is a setting). So does a combo that builds up to it and lasts at least half a second (so one slap that bounces doesn't), unless one of its slaps was a level 1 graze (so the steady shaking of a bumpy train doesn't). Fair warning: a shell command Claude already started keeps running in the background; the slap stops Claude, not the command.
Only the Claude Code session you used last reacts to a slap (the one you last typed a prompt or a /slaps command in). One laptop, one victim.
Each face series is one character: five faces, one per level, and a voice. Switch with /slaps who <series> (/slaps who lists them), or under "Face series" in /config.
| Series | Voice | Face |
|---|---|---|
sakura | sakura | <img src="plugin/assets/faces/sakura/level_1.png" width="48" alt="Sakura"> |
natsu | natsu | <img src="plugin/assets/faces/natsu/level_1.png" width="48" alt="Natsu"> |
aki | aki | <img src="plugin/assets/faces/aki/level_1.png" width="48" alt="Aki"> |
Faces are drawn as colored half-block characters, so they show up in any truecolor terminal (Warp, iTerm2, Ghostty, …), pixel-art style, in the biggest size that fits above your prompt.
Real, full-resolution pictures need a terminal with kitty graphics Unicode placeholders, which today means Ghostty or kitty. Run /slaps image to see what yours can do. Warp and Terminal.app currently can't (not our fault, we checked).
Open /config in Claude Code (or /plugin configure spank@spank-claude):
| Setting | Default | What it does |
|---|---|---|
| Slap sensitivity (g) | 0.05 | Smallest shake that counts, and where level 1 starts. Typing counts? Raise it. |
| Level that stops Claude | 4 | With /slaps claude on, this level or harder stops a turn. |
| Face series | sakura | sakura, natsu or aki: whose face pops up and voice yelps. |
| Face size | large | large (24 rows), medium (16), small (12), or off. |
| Voice volume | 1 | 0 is silent, up to 4 for open-plan offices. |
Changes apply right away. Not sure what sensitivity to pick? Run /slaps calibrate: type anything for 6 seconds (don't press Enter, or Claude gets it), then knock on the desk 3 times, and it sets the sensitivity between the two for you. The 6 seconds start at your first key and the knocking at your first knock, so take your time reading; it waits up to 30 seconds for each. The status line then shows it, e.g. spank: armed (0.077g).
MacBook IMU (AppleSPUHIDDevice, ~1 kHz)
│ IOKit HID reports, x/y/z in 1/65536 g
▼
slapd (Swift) ── removes gravity, finds the peak
│ {"type":"slap","ts":…,"peak":0.42} on stdout
▼
spank plugin (Claude Code hooks) ── grades it 1–5 for your sensitivity
├─ status line + toast
├─ face above the prompt (Raster cells, 3 s)
├─ voice clip per level
└─ optional: note to Claude / stop its turn
Run straight from a clone (uninstall the marketplace copy first, or you get two of her):
git clone https://github.com/slima4/spank-claude && cd spank-claude
claude --plugin-dir "$PWD/plugin"
make slapd # build the sensor reader by hand
make raw # watch the live shake
make faces # rebuild face cells from assets/faces/<series>/*.png
claude plugin validate . # the marketplace and the plugin
claude plugin test plugin # the tests
Installed copies are kept per version, so bump version in plugin/.claude-plugin/plugin.json when you ship a change.
To add a face series, say hana:
assets/faces/hana/level_<1-5>.png (square, on white)."hana" to the face_series options in plugin/.claude-plugin/plugin.json.make faces. It draws the cells into plugin/hooks/faces.ts, writes the 256px pictures to plugin/assets/faces/hana/, and warns if step 2 is missing.plugin/hooks/series.ts, add hana to SERIES. It can borrow an existing voice (voice: 'sakura'), or get its own: clips in plugin/assets/voices/hana/level_<1-5>.mp3 and their captions in VOICES.MIT. Slap responsibly.
hooks/register.tsx 797 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { FaceShown, Slap } from '../types'
5import { FACES } from './faces'
6import type { SeriesId } from './faces'
7import { DEFAULT_SERIES, SERIES, VOICES, isSeries } from './series'
8
9const count = atom({ plugin: 'spank', key: 'count' } as const, 0)
10const last = atom({ plugin: 'spank', key: 'last' } as const, null)
11// The main thread's running turn, which a hard slap stops.
12const turn = atom({ plugin: 'spank', key: 'turn' } as const, null)
13const face = atom({ plugin: 'spank', key: 'face' } as const, null)
14// Whether /slaps image is drawing its test picture.
15const probe = atom({ plugin: 'spank', key: 'probe' } as const, false)
16
17// Slaps this close together during a turn reach Claude as one note, told at
18// most this long after the first, so slapping on and on still gets through.
19const BURST_MS = 1500
20const BURST_MAX_MS = 4000
21// Slaps at most this far apart, by the sensor's clock, are a combo: each
22// counts one level above the one before, or its own if that is harder. A
23// combo stops Claude only once it has lasted COMBO_STOP_MS, so one slap that
24// bounces never does.
25const COMBO_MS = 1000
26const COMBO_STOP_MS = 500
27// How long a slap's face stays above the prompt.
28const FACE_MS = 3000
29// How long a slap's toast stays.
30const TOAST_MS = 4000
31// How long a clip plays before a slap no harder than it may cut it off.
32const VOICE_MS = 400
33// The tallest face per face_size setting, in rows; 0 draws none.
34const FACE_ROWS: Record<string, number> = { large: 24, medium: 16, small: 12, off: 0 }
35// Room the face leaves beside it for its line.
36const FACE_TEXT_COLUMNS = 24
37// /slaps calibrate: how long each step listens (from the first key, and from
38// the first knock), how long it waits for either, and the least typing it
39// assumes. The sensor rests around 0.01g, so a calibration with no typing
40// still lands well above that.
41const CALIBRATE_TYPING_MS = 6000
42const CALIBRATE_KNOCKING_MS = 8000
43const CALIBRATE_WAIT_MS = 30000
44const CALIBRATE_FLOOR = 0.025
45// How long past its step's longest a calibration waits on its reader: past
46// this, a reader gone quiet is given up on, so it cannot keep swallowing slaps.
47const CALIBRATE_SLACK_MS = 5000
48// How long /slaps image shows its test picture.
49const PROBE_MS = 10000
50// The levels' scale, in g: level 1 starts at the sensitivity, but never
51// below LEVEL_1_G, so a lower one lets softer taps count (as level 1) without
52// making a moderate slap level 4; level 5 starts at LEVEL_5_G, a firm palm,
53// but at least LEVEL_SPAN times level 1, so a sensitivity near 1g still has
54// five levels.
55const LEVEL_1_G = 0.05
56const LEVEL_5_G = 1
57const LEVEL_SPAN = 4
58
59// slapd's lines: it opened the sensor, heard a slap (when, and how hard), or
60// (with --raw, every 0.1s) the loudest shake of that moment.
61type Line = { type: 'start' } | { type: 'slap'; ts: number; peak: number } | { type: 'raw'; peak: number }
62
63function parseLine(line: string): Line | undefined {
64 try {
65 const v = JSON.parse(line) as Record<string, unknown>
66 if (v.type === 'start') return { type: 'start' }
67 if (v.type === 'raw' && typeof v.peak === 'number') return { type: 'raw', peak: v.peak }
68 if (v.type === 'slap' && typeof v.ts === 'number' && typeof v.peak === 'number') {
69 return { type: 'slap', ts: v.ts, peak: v.peak }
70 }
71 } catch {}
72 return undefined
73}
74
75// Splits a stream's text into whole lines, keeping a partial last line for the
76// next piece.
77function lineSplitter() {
78 let pending = ''
79 return (text: string) => {
80 pending += text
81 const lines = pending.split('\n')
82 pending = lines.pop() ?? ''
83 return lines
84 }
85}
86
87// A calibration's steps: waiting for the first key, typing, waiting for the
88// first knock, knocking. The prompt.edit hook notes when the first key came.
89type Calibration = { step: 'waiting' | 'typing' | 'ready' | 'knocking'; typedAt?: number }
90
91// When the slaps happened: during a turn, during a turn a hard one stopped,
92// during a turn a combo stopped, or between turns (told once, as the next
93// turn starts).
94type Moment = 'turn' | 'stopped' | 'combo' | 'idle'
95
96function note(slaps: readonly Slap[], moment: Moment) {
97 const strongest = slaps.reduce((a, b) => (b.peak > a.peak ? b : a))
98 const what = slaps.length === 1 ? 'slapped their laptop' : `slapped their laptop ${slaps.length} times`
99 const reading = `strongest hit level ${strongest.level} of 5, ${strongest.peak.toFixed(2)}g`
100 const when = moment === 'idle' ? 'Since your last reply the user physically' : 'The user just physically'
101 const how = moment === 'stopped' ? 'It was hard enough' : 'They came fast enough'
102 const ask =
103 moment === 'stopped' || moment === 'combo'
104 ? `${how} to stop your turn. Acknowledge the slap briefly and check with the user before redoing that work.`
105 : 'Take it as nonverbal frustration with what you are doing: acknowledge it briefly, reconsider your current approach, and ask what is wrong if it is not clear.'
106
107 return `[spank] ${when} ${what} (accelerometer; ${reading}). ${ask}`
108}
109
110// Slaps not yet told to Claude, when the first of them came, the timer that
111// tells it, the combo under way (its slaps, when the first and the last hit,
112// the softest level among them, and the level it is at), its end toast still
113// to come (the timer, and how to show it at once), the last slap toast (when,
114// and its level), the voice clip playing (its level, when it started, and how
115// to stop it), the timer that hides the face, a calibration under way, and
116// the status line the sensor last set (which a calibration puts back). Reset
117// by register, so each load starts clean.
118let burst: Slap[] = []
119let burstSince = 0
120let burstTimer: Timer | undefined
121let combo: { count: number; since: number; at: number; weakest: number; level: number } | undefined
122let comboEnd: { timer: Timer; show: () => Promise<void> } | undefined
123let toast: { at: number; level: number } | undefined
124let playing: { level: number; at: number; stop: AbortController } | undefined
125let faceTimer: Timer | undefined
126let calibration: Calibration | undefined
127let sensorStatus: string | undefined
128// /slaps image's picture (PNG, base64), and the band's id for blitting it.
129let probePng: string | undefined
130let bandRequestId: string | undefined
131
132// The config menu's values (the manifest's userConfig). A change there reloads
133// the module, so register reads them afresh.
134// `levels` is where levels 2 to 5 start, in g, for the sensitivity.
135type Settings = {
136 threshold: number
137 levels: number[]
138 stopLevel: number
139 faceRows: number
140 volume: number
141 series: SeriesId
142}
143let settings: Settings = {
144 threshold: 0.05,
145 levels: levelStarts(0.05),
146 stopLevel: 4,
147 faceRows: 24,
148 volume: 1,
149 series: DEFAULT_SERIES,
150}
151
152function readSettings(options: PluginOptions): Settings {
153 const number = (key: string, fallback: number) => {
154 const value = options[key]
155 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
156 }
157 const faceSize = options.face_size
158 // Within the manifest's 0.01-1g: the engine refuses options outside it.
159 const threshold = number('threshold', 0.05)
160 return {
161 threshold,
162 levels: levelStarts(threshold),
163 stopLevel: number('stop_level', 4),
164 faceRows: typeof faceSize === 'string' ? (FACE_ROWS[faceSize] ?? 24) : 24,
165 volume: number('volume', 1),
166 series: isSeries(options.face_series) ? options.face_series : DEFAULT_SERIES,
167 }
168}
169
170// Where levels 2 to 5 start for a sensitivity: evenly spaced on a log scale
171// between level 1 and level 5 (see LEVEL_1_G), each a fixed multiple above
172// the last.
173function levelStarts(threshold: number) {
174 const first = Math.max(threshold, LEVEL_1_G)
175 const fifth = Math.max(LEVEL_5_G, first * LEVEL_SPAN)
176 return [1, 2, 3, 4].map(k => first * (fifth / first) ** (k / 4))
177}
178
179// A slap's level, 1 to 5; any slap that counts is at least level 1.
180function levelOf(peak: number) {
181 return 1 + settings.levels.filter(start => peak >= start).length
182}
183
184// The chosen series' voice: its clips' folder and what each level says.
185function voiceOf() {
186 const id = SERIES[settings.series].voice
187 return { id, captions: VOICES[id].captions }
188}
189
190function setSensorStatus($: EngineInterface, text: string) {
191 sensorStatus = text
192 $.ui.status(text)
193}
194
195// Which session reacts when several are open: the one used last. Each session
196// writes itself into this file (in the per-user temp folder) when it starts,
197// on every prompt and on /slaps; a slap belongs to the session it names.
198async function activePath($: EngineInterface) {
199 const temp = (await $.env.get('TMPDIR')) ?? `${$.plugin.root}/`
200 return `${temp.endsWith('/') ? temp : `${temp}/`}spank-claude-active.json`
201}
202
203async function markActive($: EngineInterface) {
204 const session = await $.session.id()
205 await $.fs.write(await activePath($), JSON.stringify({ session, at: await $.clock.now() }))
206}
207
208// Unreadable or torn, the file names nobody, and every session reacts.
209async function isActive($: EngineInterface) {
210 try {
211 const { session } = JSON.parse(await $.fs.read(await activePath($))) as { session?: unknown }
212 return typeof session !== 'string' || session === (await $.session.id())
213 } catch {
214 return true
215 }
216}
217
218// The largest face of the chosen series for this level that fits the band,
219// if any does.
220function faceArt(level: number, maxRows: number, columns: number) {
221 const arts = FACES[settings.series][Math.min(Math.max(level, 1), 5) - 1] ?? []
222 return arts.findLast(art => art.rows <= Math.min(maxRows, settings.faceRows) && art.columns + FACE_TEXT_COLUMNS <= columns)
223}
224
225function showToast($: EngineInterface, at: number, level: number, text: string) {
226 toast = { at, level }
227 $.ui.toast(text, { timeoutMs: TOAST_MS })
228}
229
230// Takes back the combo's end toast still to come, showing it first if asked.
231function settleComboEnd(isShown: boolean) {
232 const end = comboEnd
233 comboEnd = undefined
234 end?.timer.cancel()
235 if (isShown) void end?.show().catch(() => {})
236}
237
238async function showFace($: EngineInterface, shown: FaceShown) {
239 faceTimer?.cancel()
240 await update($, face, () => shown)
241 faceTimer = $.clock.after(FACE_MS, () => void update($, face, () => null))
242}
243
244// Plays the level's clip, cutting off the one still playing: always if it is
245// harder, else only once that one has played VOICE_MS, so drumming at one
246// level does not stutter (a slap sooner than that stays quiet).
247function voice($: EngineInterface, level: number, now: number) {
248 if (settings.volume <= 0) return
249 if (playing !== undefined && level <= playing.level && now - playing.at < VOICE_MS) return
250 playing?.stop.abort()
251 const clip = { level, at: now, stop: new AbortController() }
252 playing = clip
253 const asset = `assets/voices/${voiceOf().id}/level_${level}.mp3`
254 $.audio
255 .play({ asset }, { signal: clip.stop.signal, gain: settings.volume })
256 .catch(() => {})
257 .finally(() => {
258 if (playing === clip) playing = undefined
259 })
260}
261
262function cancelBurstTimer() {
263 burstTimer?.cancel()
264 burstTimer = undefined
265}
266
267async function tellClaude($: EngineInterface, moment: Moment) {
268 cancelBurstTimer()
269 const slaps = burst
270 burst = []
271 if (slaps.length === 0) return
272
273 const text = note(slaps, moment)
274 // A refused or failed note must not end the listen loop that called it.
275 try {
276 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
277 } catch (error) {
278 $.ui.log(`spank: could not tell Claude "${text}": ${String(error)}`, { to: 'debug' })
279 }
280}
281
282// The loudest typing, that at least CALIBRATE_FLOOR, and the bar a knock must
283// reach: half again the floored typing.
284function typingBar(typing: readonly number[]) {
285 const typingMax = Math.max(0, ...typing)
286 const floored = Math.max(CALIBRATE_FLOOR, typingMax)
287 return { typingMax, floored, bar: floored * 1.5 }
288}
289
290// The sensitivity between typing and knocking: the geometric mean of the
291// floored typing and the softest of the three strongest knocks, each of which
292// must reach the bar. A knock is a moment louder than the ones beside it, so a
293// knock split across two moments counts once. Also returns what it decided
294// on, to log.
295function pickThreshold(typing: readonly number[], knocking: readonly number[]) {
296 const { typingMax, floored, bar } = typingBar(typing)
297 const peaks = knocking
298 .filter((peak, i) => peak > (knocking[i - 1] ?? 0) && peak >= (knocking[i + 1] ?? 0))
299 .sort((a, b) => b - a)
300 const clear = peaks.filter(peak => peak >= bar)
301 const softest = clear[2]
302 if (softest === undefined) {
303 // A knock louder than typing but short of the bar, or none reaching it,
304 // was too soft; otherwise the missing ones ran together or never came.
305 const isSoft = clear.length === 0 || peaks.some(peak => peak > floored && peak < bar)
306 const advice = isSoft ? 'knock harder' : 'knock 3 separate times, a second apart'
307 return { reason: `heard ${clear.length} of 3 knocks reaching ${inG(bar)}; ${advice}`, typingMax, bar, peaks } as const
308 }
309 const threshold = Math.round(Math.min(1, Math.sqrt(floored * softest)) * 1000) / 1000
310 return { threshold, typingMax, softest, bar, peaks }
311}
312
313function inG(value: number) {
314 return `${value.toFixed(3)}g`
315}
316
317// An outcome told after the fact (a calibration's, a failed switch): a toast,
318// and a line in the transcript, which keeps the whole text when the toast
319// gets only one line.
320function report($: EngineInterface, text: string) {
321 $.ui.toast(text, { timeoutMs: 10000 })
322 $.ui.log(`spank: ${text}`)
323}
324
325// /slaps calibrate: a slapd of its own (--raw: the loudest shake every 0.1s)
326// listens to typing, then to knocks, and the sensitivity setting goes between
327// them. Typing is timed from the first key in the prompt box, and knocking
328// from the first moment loud enough to be a knock, so time spent reading the
329// instructions counts for neither. Slaps are ignored meanwhile, and one runs
330// at a time. Steps follow the clock as readings arrive, and leaving the loop
331// stops that slapd.
332async function calibrate($: EngineInterface) {
333 // Set before any await, so a /slaps calibrate right behind this one sees it.
334 const run: Calibration = { step: 'waiting' }
335 calibration = run
336 // reader.return() waits behind a pull still waiting for a line, so each
337 // pull races the backstop instead. Each step sets it anew.
338 let giveUp = () => {}
339 const quiet = new Promise<'quiet'>(resolve => (giveUp = () => resolve('quiet')))
340 let backstop = $.clock.after(CALIBRATE_WAIT_MS + CALIBRATE_SLACK_MS, () => giveUp())
341 const rearm = (stepMs: number) => {
342 backstop.cancel()
343 backstop = $.clock.after(stepMs + CALIBRATE_SLACK_MS, () => giveUp())
344 }
345 const heard = { typing: [] as number[], knocking: [] as number[] }
346 let failure: string | undefined
347 try {
348 // When the step began: the calibration, the first key, the end of the
349 // typing, the first knock.
350 let stepAt = await $.clock.now()
351 let bar = 0
352 $.ui.status(
353 `spank: calibrating 1/2: start typing, without pressing Enter (${CALIBRATE_TYPING_MS / 1000}s from the first key)`,
354 )
355 const reader = $.process.spawn({ argv: [`${$.plugin.root}/bin/slapd`, '--raw', '--threshold', '100'] })
356 const lines = lineSplitter()
357 let lastError = ''
358 try {
359 reading: for (;;) {
360 const pulled = await Promise.race([reader.next(), quiet])
361 if (pulled === 'quiet') {
362 failure = 'the sensor went quiet'
363 break
364 }
365 if (pulled.done === true) {
366 failure = `the sensor reader stopped (${lastError || 'slapd exited'})`
367 break
368 }
369 const { stream, text } = pulled.value
370 if (stream === 'stderr') {
371 lastError = text.trim().split('\n').pop() ?? lastError
372 continue
373 }
374 for (const line of lines(text)) {
375 const parsed = parseLine(line)
376 if (parsed?.type !== 'raw') continue
377 const now = await $.clock.now()
378 if (run.step === 'waiting') {
379 if (run.typedAt === undefined) {
380 if (now - stepAt < CALIBRATE_WAIT_MS) continue
381 failure = `nothing was typed in the prompt box within ${CALIBRATE_WAIT_MS / 1000}s`
382 break reading
383 }
384 run.step = 'typing'
385 stepAt = run.typedAt
386 rearm(CALIBRATE_TYPING_MS)
387 $.ui.status(`spank: calibrating 1/2: keep typing (${CALIBRATE_TYPING_MS / 1000}s)`)
388 }
389 if (run.step === 'typing') {
390 if (now - stepAt < CALIBRATE_TYPING_MS) {
391 heard.typing.push(parsed.peak)
392 continue
393 }
394 run.step = 'ready'
395 stepAt = now
396 bar = typingBar(heard.typing).bar
397 rearm(CALIBRATE_WAIT_MS)
398 $.ui.status(
399 `spank: calibrating 2/2: knock on the desk 3 times (${CALIBRATE_KNOCKING_MS / 1000}s from the first knock)`,
400 )
401 $.ui.toast('Now knock on the desk 3 times', { timeoutMs: CALIBRATE_KNOCKING_MS })
402 }
403 if (run.step === 'ready') {
404 // Typing on past the toast stays under the bar.
405 if (parsed.peak < bar) {
406 if (now - stepAt < CALIBRATE_WAIT_MS) continue
407 failure = `heard no knock reaching ${inG(bar)} within ${CALIBRATE_WAIT_MS / 1000}s; knock harder`
408 break reading
409 }
410 run.step = 'knocking'
411 stepAt = now
412 rearm(CALIBRATE_KNOCKING_MS)
413 $.ui.status(`spank: calibrating 2/2: keep knocking (${CALIBRATE_KNOCKING_MS / 1000}s)`)
414 }
415 if (now - stepAt >= CALIBRATE_KNOCKING_MS) break reading
416 heard.knocking.push(parsed.peak)
417 }
418 }
419 } finally {
420 // Stops slapd; a pull still waiting holds this until slapd writes.
421 reader.return({ code: null, signal: null }).catch(() => {})
422 }
423 } catch (error) {
424 failure = `the sensor could not be read (${String(error)})`
425 } finally {
426 backstop.cancel()
427 if (calibration === run) calibration = undefined
428 }
429
430 $.ui.status(sensorStatus)
431 if (failure !== undefined) {
432 report($, `Calibration failed: ${failure}.`)
433 return
434 }
435 const picked = pickThreshold(heard.typing, heard.knocking)
436 const peaks = picked.peaks.slice(0, 5).map(inG).join(', ')
437 $.ui.log(`spank: calibration: typing ${inG(picked.typingMax)}, bar ${inG(picked.bar)}, knock peaks [${peaks}]`, {
438 to: 'debug',
439 })
440 if (picked.threshold === undefined) {
441 report($, `Calibration failed: ${picked.reason}.`)
442 return
443 }
444 // Told before saving: saving reloads the plugin, which may drop anything
445 // this module says afterwards.
446 report($, `Sensitivity set to ${picked.threshold}g (typing ${inG(picked.typingMax)}, knocks ${inG(picked.softest)}).`)
447 const unsaved = await saveSetting($, 'threshold', picked.threshold, 'sensitivity').catch(error => String(error))
448 if (unsaved !== undefined) {
449 report($, `Could not save the sensitivity: ${unsaved}. Set "Slap sensitivity" in /config.`)
450 }
451}
452
453// Writes a userConfig option as the person would in /config; the module then
454// reloads with it. The row is looked up, not spelled, since its key depends
455// on how the plugin was loaded. Resolves undefined once saved, or why not,
456// calling the setting by its label.
457async function saveSetting(
458 $: EngineInterface,
459 option: string,
460 value: string | number,
461 label: string,
462): Promise<string | undefined> {
463 const rows = await $.config.list()
464 const row = rows.find(
465 r => r.key === `${$.plugin.name}.${option}` || (r.provider.plugin === $.plugin.name && r.key.endsWith(`.${option}`)),
466 )
467 if (row === undefined) return `no ${label} row in /config`
468 const set = await $.config.set({ key: row.key, value })
469 return set.deny
470}
471
472function titled(id: string) {
473 return id.charAt(0).toUpperCase() + id.slice(1)
474}
475
476// /slaps who [series]: lists the face series, or switches to one by saving
477// the face_series setting. Saving reloads the module, so the reply goes out
478// first and only a failure is told afterwards; until the reload the series is
479// switched here already, and switched back if saving fails. A calibration
480// under way would not survive the reload, so it holds the switch off.
481function who($: EngineInterface, wanted: string) {
482 const ids = Object.keys(SERIES)
483 if (wanted === '') return ids.map(id => (id === settings.series ? `${id} (current)` : id)).join(', ')
484 if (!isSeries(wanted)) return `No face series "${wanted}". Try: ${ids.join(', ')}.`
485 if (wanted === settings.series) return `${titled(wanted)} already. Slap away.`
486 if (calibration !== undefined) return 'Calibrating; switch after it finishes.'
487
488 const { series } = settings
489 settings = { ...settings, series: wanted }
490 void saveSetting($, 'face_series', wanted, 'face series')
491 .catch(error => String(error))
492 .then(unsaved => {
493 if (unsaved === undefined) return
494 settings = { ...settings, series }
495 report($, `Could not switch to ${titled(wanted)}: ${unsaved}. Set "Face series" in /config.`)
496 })
497 .catch(() => {})
498 return `${titled(wanted)} now. Slap away.`
499}
500
501async function onSlap($: EngineInterface, slap: Slap) {
502 if (calibration !== undefined) return
503 if (!(await isActive($))) return
504
505 const now = await $.clock.now()
506 const gap = combo === undefined ? undefined : slap.ts - combo.at
507 const prior = gap !== undefined && gap >= 0 && gap <= COMBO_MS ? combo : undefined
508 // The slap's level in its combo, which never drops until the combo ends:
509 // what she shows and says, and what stops Claude. The score keeps what the
510 // sensor read.
511 const level = Math.min(Math.max(slap.level, (prior?.level ?? 0) + 1), 5)
512 combo = {
513 count: (prior?.count ?? 0) + 1,
514 since: prior?.since ?? slap.ts,
515 at: slap.ts,
516 weakest: Math.min(prior?.weakest ?? slap.level, slap.level),
517 level,
518 }
519
520 const n = await update($, count, c => c + 1)
521 await update($, last, () => slap)
522 await $.store.set('total', Number((await $.store.get('total')) ?? 0) + 1)
523
524 // Telling Claude and stopping its turn are off unless /slaps claude on.
525 const isClaudeOn = (await $.store.get('claude')) === true
526 const turnId = await read($, turn)
527 // A combo stops a turn only if none of its slaps barely registered (level
528 // 1), so steady shaking, a train or heavy typing, never does, and only once
529 // it has lasted COMBO_STOP_MS.
530 const isHard = slap.level >= settings.stopLevel
531 const isComboStop = combo.weakest >= 2 && slap.ts - combo.since >= COMBO_STOP_MS
532 const isStopping = isClaudeOn && turnId !== null && level >= settings.stopLevel && (isHard || isComboStop)
533
534 setSensorStatus($, `spank: ${n} this session, last L${slap.level} (${slap.peak.toFixed(2)}g)`)
535
536 const { captions } = voiceOf()
537 const line = captions[level - 1] ?? captions[0]
538 // A combo puts up a toast as it starts (unless one as hard is showing) and
539 // one when it ends, saying how far it got, not one per slap; a stop always
540 // says so.
541 // The end toast waits for a second with no slap; one still to come when a
542 // new combo starts is the last one's, shown then.
543 const inRow = combo.count
544 settleComboEnd(inRow === 1)
545 if (inRow > 1 && !isStopping) {
546 const text = `${line} L${level}, ${inRow} in a row`
547 const show = async () => {
548 comboEnd = undefined
549 if (calibration !== undefined || !(await isActive($))) return
550 showToast($, await $.clock.now(), level, text)
551 }
552 comboEnd = { timer: $.clock.after(COMBO_MS, () => void show().catch(() => {})), show }
553 }
554 if (isStopping) {
555 showToast($, now, level, `Stopped Claude. L${level}`)
556 } else if (inRow === 1 && (toast === undefined || now - toast.at >= TOAST_MS || level > toast.level)) {
557 showToast($, now, level, `${line} L${level}`)
558 }
559 if ((await $.store.get('muted')) !== true) voice($, level, now)
560 await showFace($, { level, peak: slap.peak, line, combo: inRow })
561
562 if (!isClaudeOn) return
563 if (burst.length === 0) burstSince = now
564 burst.push(slap)
565 if (turnId === null) return // told as the next turn starts
566 if (isStopping) {
567 await update($, turn, () => null)
568 await $.turn.abort({ turnId }).catch(() => {})
569 await tellClaude($, isHard ? 'stopped' : 'combo')
570 return
571 }
572 cancelBurstTimer()
573 const wait = Math.max(0, Math.min(BURST_MS, burstSince + BURST_MAX_MS - now))
574 burstTimer = $.clock.after(wait, () => void tellClaude($, 'turn'))
575}
576
577// slapd ships as Swift source and is built on first run (and again when its
578// source is newer than the build), so installing needs only Xcode's command
579// line tools. Builds to a temporary name and renames, so two sessions starting
580// together never run a half-written binary. Resolves an error, or undefined.
581async function buildSlapd($: EngineInterface): Promise<string | undefined> {
582 const source = `${$.plugin.root}/slapd/main.swift`
583 const binary = `${$.plugin.root}/bin/slapd`
584 if ((await $.fs.exists(binary)) && (await $.fs.stat(binary)).mtimeMs >= (await $.fs.stat(source)).mtimeMs) {
585 return undefined
586 }
587
588 setSensorStatus($, 'spank: building the sensor reader (first run, about 20s)')
589 const temporary = `${binary}.${await $.clock.now()}.tmp`
590 await $.process.run(['/bin/mkdir', '-p', `${$.plugin.root}/bin`])
591 const built = await $.process.run(['/usr/bin/swiftc', '-O', '-swift-version', '5', '-o', temporary, source], {
592 timeoutMs: 300_000,
593 })
594 if (built.exitCode !== 0) {
595 if (/xcode-select|developer path|CommandLineTools/i.test(built.stderr)) {
596 return 'needs Xcode command line tools: run xcode-select --install'
597 }
598 return `build failed: ${built.stderr.trim().split('\n').pop() ?? `swiftc exited ${built.exitCode}`}`
599 }
600 const moved = await $.process.run(['/bin/mv', '-f', temporary, binary])
601 return moved.exitCode === 0 ? undefined : `build failed: ${moved.stderr.trim()}`
602}
603
604// Runs slapd for the module's life: it prints one JSON line per hit, and
605// leaving this loop (a reload, the session ending) kills it.
606async function listen($: EngineInterface) {
607 const buildError = await buildSlapd($).catch(error => `build failed: ${String(error)}`)
608 if (buildError !== undefined) {
609 setSensorStatus($, `spank: sensor off (${buildError})`)
610 return
611 }
612
613 const slapd = $.process.spawn({ argv: [`${$.plugin.root}/bin/slapd`, '--threshold', String(settings.threshold)] })
614 const lines = lineSplitter()
615 let lastError = ''
616
617 try {
618 for await (const { stream, text } of slapd) {
619 if (stream === 'stderr') {
620 lastError = text.trim().split('\n').pop() ?? lastError
621 continue
622 }
623 for (const line of lines(text)) {
624 const parsed = parseLine(line)
625 if (parsed?.type === 'start') setSensorStatus($, `spank: armed (${settings.threshold}g)`)
626 if (parsed?.type === 'slap') await onSlap($, { ts: parsed.ts, peak: parsed.peak, level: levelOf(parsed.peak) })
627 }
628 }
629 } catch (error) {
630 lastError = String(error)
631 }
632
633 setSensorStatus($, `spank: sensor off (${lastError || 'slapd exited'})`)
634}
635
636export const register: Register = (on, options) => {
637 settings = readSettings(options)
638 burst = []
639 burstSince = 0
640 cancelBurstTimer()
641 combo = undefined
642 settleComboEnd(false)
643 toast = undefined
644 playing?.stop.abort()
645 playing = undefined
646 faceTimer?.cancel()
647 faceTimer = undefined
648 calibration = undefined
649 sensorStatus = undefined
650 probePng = undefined
651 bandRequestId = undefined
652
653 on('session.start', async ($, e, next) => {
654 const started = await next(e)
655 // A reload drops the timers that would hide a face or picture left showing.
656 await update($, face, () => null)
657 await update($, probe, () => false)
658 await markActive($).catch(() => {})
659 await $.command.register({
660 name: 'slaps',
661 description: 'How many times you hit the laptop; pick who gets hit, calibrate, mute, or let slaps reach Claude',
662 argumentHint: '[who [series]|calibrate|mute|unmute|claude on|claude off|image]',
663 immediate: true,
664 })
665 void listen($)
666 return started
667 })
668
669 on('prompt.submit', async ($, e, next) => {
670 await markActive($).catch(() => {})
671 return next(e)
672 })
673
674 // A calibration times its typing from the first key in the prompt box.
675 on('prompt.edit', async ($, e, next) => {
676 const run = calibration
677 if (run?.step === 'waiting' && run.typedAt === undefined) {
678 const now = await $.clock.now()
679 run.typedAt ??= now
680 }
681 return next(e)
682 })
683
684 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
685 // Read before any early return, so a later change redraws the band.
686 const shown = await read($, face)
687 const png = (await read($, probe)) ? probePng : undefined
688 if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
689 const art = shown === null ? undefined : faceArt(shown.level, e.props.maxRows, e.props.bodyColumns)
690 if (art === undefined && png === undefined) return next(e)
691
692 bandRequestId = e.requestId
693 const { Box, Image, Raster, Text } = $.ui.resolve(e)
694
695 return (
696 <Box flexDirection="row" alignItems="center" gap={2}>
697 {png !== undefined ? (
698 <Image key="probe" source={{ png }} columns={24} rows={12} alt="[spank: this terminal drew no picture]" />
699 ) : null}
700 {art !== undefined && shown !== null ? (
701 <Box flexDirection="row" alignItems="center" gap={2}>
702 <Raster key="face" columns={art.columns} rows={art.rows} cells={art.cells} />
703 <Box flexDirection="column">
704 <Text bold>{shown.line}</Text>
705 <Text dimColor>
706 level {shown.level} of 5, {shown.peak.toFixed(2)}g{shown.combo > 1 ? `, ${shown.combo} in a row` : ''}
707 </Text>
708 </Box>
709 </Box>
710 ) : null}
711 </Box>
712 )
713 })
714
715 on('turn.start', async ($, e, next) => {
716 await update($, turn, () => e.turnId)
717 // A combo begun before the turn does not carry into it, so it cannot stop
718 // the turn on its first slap.
719 combo = undefined
720 await tellClaude($, 'idle')
721 return next(e)
722 })
723
724 // A burst still waiting when the turn ends is told with the next turn.
725 on('turn.complete', async ($, e, next) => {
726 if ((await read($, turn)) === e.turnId) {
727 await update($, turn, () => null)
728 cancelBurstTimer()
729 }
730 return next(e)
731 })
732
733 on('command.run', { command: 'slaps' }, async ($, e) => {
734 await markActive($).catch(() => {})
735 const arg = e.args.trim()
736 const [subcommand, ...rest] = arg.split(/\s+/)
737 if (subcommand === 'who') return { text: who($, rest.join(' ').toLowerCase()) }
738 if (arg === 'mute' || arg === 'unmute') {
739 await $.store.set('muted', arg === 'mute')
740 return { text: arg === 'mute' ? 'Laptop voice off.' : 'Laptop voice on.' }
741 }
742 if (arg === 'calibrate') {
743 if (calibration !== undefined) return { text: 'Already calibrating; wait for its result.' }
744 void calibrate($)
745 return {
746 text:
747 `Calibrating. Start typing in the prompt box, without pressing Enter: ` +
748 `${CALIBRATE_TYPING_MS / 1000} seconds from your first key. When the toast says so, knock on the desk ` +
749 `3 times: ${CALIBRATE_KNOCKING_MS / 1000} seconds from your first knock.`,
750 }
751 }
752 if (arg === 'image') {
753 // Draws a real picture above the prompt, then asks the terminal to take
754 // it again: a refusal says the terminal drew the alt text instead.
755 const picture = `${$.plugin.root}/assets/faces/${settings.series}/level_1.png`
756 const bytes = await $.fs.read(picture, { as: 'bytes' }).catch(() => undefined)
757 if (bytes === undefined) return { text: `Image probe: no picture to try (${picture} is missing).` }
758 const { base64 } = bytes
759 probePng = base64
760 await update($, probe, () => true)
761 await $.clock.sleep(1000)
762 const blitted =
763 bandRequestId === undefined
764 ? { deny: 'the band above the prompt was not drawn' }
765 : await $.ui.blit({ requestId: bandRequestId, key: 'probe', source: { png: base64 } })
766 $.clock.after(PROBE_MS, () => void update($, probe, () => false))
767
768 return {
769 text:
770 blitted.deny === undefined
771 ? 'Image probe: the terminal took the picture. You should see a face above the prompt for 10s.'
772 : `Image probe: no picture here (${blitted.deny}).`,
773 }
774 }
775 if (arg === 'claude on' || arg === 'claude off') {
776 const isOn = arg === 'claude on'
777 await $.store.set('claude', isOn)
778 if (!isOn) {
779 burst = []
780 cancelBurstTimer()
781 }
782 return {
783 text: isOn
784 ? 'Slaps now reach Claude, and a hard one stops its turn.'
785 : 'Slaps no longer reach Claude or stop its turn.',
786 }
787 }
788
789 const n = await read($, count)
790 const slap = await read($, last)
791 const total = Number((await $.store.get('total')) ?? 0)
792 const lastText = slap ? `last one level ${slap.level}, ${slap.peak.toFixed(2)}g` : 'none yet'
793
794 return { text: `${n} slaps this session, ${total} all time; ${lastText}.` }
795 })
796}
797hooks/series.ts 32 lines1import type { SeriesId } from './faces'
2
3// A voice: a clip per slap level, assets/voices/<voice>/level_<1-5>.mp3, and
4// what each one says, shown beside the face and in the toast.
5export type Voice = { captions: readonly [string, string, string, string, string] }
6
7export const VOICES = {
8 sakura: { captions: ['んっ!', 'あっ!', 'いたっ!', 'きゃっ!', 'あぁっ…!'] },
9 natsu: { captions: ['えっ!', 'うっ!', 'いてっ!', 'やっ!', 'うわぁっ!'] },
10 aki: { captions: ['ひゃっ!', 'あれっ!', 'いたぁ!', 'いやっ!', 'きゃあっ!'] },
11} as const satisfies Record<string, Voice>
12
13export type VoiceId = keyof typeof VOICES
14
15// A face series: its faces are assets/faces/<series>/level_<1-5>.png (drawn
16// into faces.ts by `make faces`), and it speaks with one of the voices; a
17// series with no voice of its own can borrow another's. The face_series
18// setting picks one, so its options in plugin.json list every series.
19export type Series = { voice: VoiceId }
20
21export const SERIES: Record<SeriesId, Series> = {
22 sakura: { voice: 'sakura' },
23 natsu: { voice: 'natsu' },
24 aki: { voice: 'aki' },
25}
26
27export const DEFAULT_SERIES: SeriesId = 'sakura'
28
29export function isSeries(value: unknown): value is SeriesId {
30 return typeof value === 'string' && Object.hasOwn(SERIES, value)
31}
32types/index.d.ts 12 lines1export type Slap = { ts: number; peak: number; level: number }
2
3// The face drawn above the prompt for a moment after a slap: its level (a
4// combo's added), the sensor's reading, and how many slaps came in a row.
5export type FaceShown = { level: number; peak: number; line: string; combo: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 spank: { count: number; last: Slap | null; turn: string | null; face: FaceShown | null; probe: boolean }
10 }
11}
12