Camera pane with pose landmarks, spoken coaching and optional Muse EEG for the Built to Move mobility tests (macOS)

A Claude Code mod that adds a camera pane: your webcam feed with MediaPipe pose landmarks drawn in green, the matching Built to Move illustration beside it, and spoken coaching while you do the test.
/move-coach [test] [camera#] opens the pane. With no test it's a plain camera preview. /move-coach stop ends a run.mcp__move-coach__camera_test lets Claude run a test. The built-to-move-mobility-test skill uses it.sit_and_rise, couch, airport_scanner, shoulder_rotation, squat, solec, old_man.helper/pose_coach.py: a uv script (mediapipe 0.10.14, opencv). It captures the camera, runs the Pose Landmarker (model cached in ~/.cache/move-coach), and runs one state machine per test. It speaks cues and streams JSON lines to the mod.hooks/register.tsx: the pane, the slash command and the tool. Ghostty draws the camera as a PNG Image; the Desktop app draws it as an Svg that embeds the JPEG frame with a vector skeleton on top.assets/book/: illustrations, optional. The book's illustrations are copyrighted and aren't shipped; put your own in ~/.config/move-coach/book/ (see assets/book/README.md). The pane works without them.By default the coach speaks with the built-in macOS voice (say); nothing to set up. For a more natural voice, add an ElevenLabs API key in the plugin's settings (/config → move-coach → "ElevenLabs API key", stored in secure storage), and optionally a voice ID. The key reaches the helper in a private file it deletes on read, never on the command line. ~/.config/move-coach/keys.env with ELEVENLABS_API_KEY=... also works. If ElevenLabs fails for a cue, that cue falls back to say. Audio clips are cached in ~/.cache/move-coach/tts.
macOS with a camera, uv (the helpers are uv scripts that install their own Python packages on first run), and the Xcode command-line tools (xcode-select --install, used once to build the small "Move Coach Camera" app). Optional: ffmpeg (camera names), a Muse headband and liblsl (brew install labstreaminglayer/tap/lsl) for EEG.
The Claude app starts its sessions with camera responsibility disclaimed, so a helper spawned straight from the mod can never get camera access (and macOS can't prompt for it). helper/launch.sh therefore builds a tiny Move Coach Camera.app (in ~/Library/Application Support/move-coach/, from helper/camera-app/) and runs the helper inside it with open, relaying its stdout through a FIFO. The first run shows the macOS camera prompt for "Move Coach Camera"; the grant then sticks across Claude updates. Revoke it in System Settings → Privacy & Security → Camera.
/move-coach eeg (or the pane's Connect Muse EEG button, or the tool's eeg_start action) runs helper/eeg_stream.py, which reads a Muse through muse-lsl. It attaches to an LSL EEG stream if one is already running (muselsl stream), and otherwise scans Bluetooth for a Muse and starts muselsl stream itself. /move-coach eeg Muse-1A2B picks a headband by name; /move-coach eeg stop ends it.
The pane draws the last 4 s of each channel (TP9, AF7, AF8, TP10), a contact dot per sensor, and relative band power over the last second. While a test runs, its result also carries the mean band power during the test (eeg_mean_relative_band_power).
The helper runs inside "Move Coach Camera.app", like the camera, so macOS asks once to allow Bluetooth. pylsl needs liblsl: if the wheel doesn't bundle it, brew install labstreaminglayer/tap/lsl.
hooks/register.tsx 1061 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CoachView, EegView, Scores, TestKey } from '../types'
5
6const PANE = 'move-coach'
7const TOOL = 'camera_test'
8
9const TESTS: Record<TestKey, { title: string; images: { file: string; caption: string }[] }> = {
10 free: { title: 'Camera preview', images: [] },
11 sit_and_rise: {
12 title: 'VS1 · Sit-and-Rise Test',
13 images: [{ file: 'sit-and-rise', caption: 'Cross one foot in front, sit cross-legged, stand up: no hands, no knees' }],
14 },
15 couch: {
16 title: 'VS3 · Couch Test',
17 images: [
18 { file: 'couch-floor-p1', caption: 'Floor P1: knee in the corner, shin up the wall, hands down' },
19 { file: 'couch-floor-p2', caption: 'Floor P2: front foot planted, knee at 90°' },
20 { file: 'couch-floor-p3', caption: 'Floor P3: torso upright' },
21 { file: 'couch-couch-p1', caption: 'Couch P1: knee in the seat, shin up the back' },
22 { file: 'couch-couch-p2', caption: 'Couch P2: front foot up on the seat' },
23 ],
24 },
25 airport_scanner: {
26 title: 'VS5 · Airport Scanner Arms-Raise Test',
27 images: [{ file: 'airport-scanner', caption: 'Face down, thumbs up, lift the pipe and hold 5 breaths' }],
28 },
29 shoulder_rotation: {
30 title: 'VS5 · Shoulder Rotation Test',
31 images: [{ file: 'shoulder-rotation', caption: 'Elbows at 90°, press the backs of your wrists down for 5 breaths' }],
32 },
33 squat: {
34 title: 'VS7 · Squat Test',
35 images: [
36 { file: 'squat-p1', caption: 'Position 1: deep, feet straight, heels down' },
37 { file: 'squat-p3', caption: 'Position 3: about chair height' },
38 { file: 'squat-p4', caption: 'Position 4: as low as you can go' },
39 ],
40 },
41 solec: {
42 title: 'VS8 · SOLEC (Stand On one Leg, Eyes Closed)',
43 images: [{ file: 'solec', caption: 'Eyes closed, one foot up, 20 seconds per side' }],
44 },
45 old_man: {
46 title: 'VS8 · Old Man Balance Test',
47 images: [{ file: 'old-man', caption: 'On one leg: sock on, shoe on, tie it' }],
48 },
49}
50
51const EMPTY: CoachView = {
52 isRunning: false,
53 test: 'free',
54 title: TESTS.free.title,
55 phase: 'idle',
56 instruction: '',
57 cue: '',
58 status: '',
59 error: '',
60 metrics: {},
61 elapsed: 0,
62 gen: 0,
63 w: 480,
64 h: 360,
65 jpeg: '',
66 png: '',
67 cells: '',
68 cols: 0,
69 rows: 0,
70 lm: [],
71 result: '',
72 isStartedByTool: false,
73}
74
75const view = atom({ plugin: 'move-coach', key: 'view' } as const, EMPTY)
76const scores = atom({ plugin: 'move-coach', key: 'scores' } as const, {} as Scores)
77/** who is being tested; scores, history and the EEG baseline are kept per person */
78const person = atom({ plugin: 'move-coach', key: 'person' } as const, '')
79/** before profiles there was one unnamed set of results; it becomes this person's */
80const LEGACY_PERSON = 'Nate'
81
82const EEG_EMPTY: EegView = { isRunning: false, status: '', error: '', device: '', channels: [], raw: [], bands: {}, quality: [], gen: 0 }
83const eeg = atom({ plugin: 'move-coach', key: 'eeg' } as const, EEG_EMPTY)
84const BAND_ORDER = ['delta', 'theta', 'alpha', 'beta', 'gamma'] as const
85const BAND_COLORS: Record<string, string> = { delta: '#7b61ff', theta: '#2f9bff', alpha: '#1fbf75', beta: '#f2a900', gamma: '#ef5b5b' }
86const TRACE_COLORS = ['#2f9bff', '#1fbf75', '#f2a900', '#ef5b5b']
87
88/** The tests the book scores (Shoulder Rotation is subjective), 10 points each. */
89export const SCORED: { key: TestKey; short: string }[] = [
90 { key: 'sit_and_rise', short: 'Sit-and-Rise' },
91 { key: 'couch', short: 'Couch' },
92 { key: 'airport_scanner', short: 'Arms Raise' },
93 { key: 'squat', short: 'Squat' },
94 { key: 'solec', short: 'SOLEC' },
95 { key: 'old_man', short: 'Old Man' },
96]
97
98export function totalOf(s: Scores): { points: number; max: number; done: number } {
99 const done = SCORED.filter(t => s[t.key] !== undefined)
100 return { points: done.reduce((n, t) => n + s[t.key]!.points, 0), max: SCORED.length * 10, done: done.length }
101}
102
103/** ▰▰▰▰▱▱▱▱▱▱ for points out of 10. */
104export function bar(points: number): string {
105 const n = Math.max(0, Math.min(10, Math.round(points)))
106 return '▰'.repeat(n) + '▱'.repeat(10 - n)
107}
108
109/** Band sums and per-channel good-contact counts over one baseline phase. */
110export type PhaseAcc = { sums: Record<string, number>; n: number; good: number[] }
111
112const emptyAcc = (): PhaseAcc => ({ sums: {}, n: 0, good: [] })
113
114/** Adds one EEG message (bands over the last second, per-channel quality) to a phase. */
115export function addSample(acc: PhaseAcc, bands: Record<string, number>, quality: string[]): void {
116 for (const b of BAND_ORDER) acc.sums[b] = (acc.sums[b] ?? 0) + (bands[b] ?? 0)
117 quality.forEach((q, i) => (acc.good[i] = (acc.good[i] ?? 0) + (q === 'good' ? 1 : 0)))
118 acc.n++
119}
120
121const r3 = (x: number) => Math.round(x * 1000) / 1000
122
123/**
124 * Mean relative band power per phase, the share of samples with good contact per channel,
125 * and whether it's usable: at least 2 channels in good contact ≥70% of each phase.
126 * Alpha should rise with the eyes closed (typically 1.5–3×); under 1.2× usually means
127 * poor contact, eyes not actually closed, or tension.
128 */
129export function summarizeBaseline(open: PhaseAcc, closed: PhaseAcc, channels: string[]) {
130 const side = (a: PhaseAcc) => ({
131 ...Object.fromEntries(BAND_ORDER.map(b => [b, a.n ? r3((a.sums[b] ?? 0) / a.n) : 0])),
132 good_contact_share: Object.fromEntries(channels.map((c, i) => [c, a.n ? Math.round(((a.good[i] ?? 0) / a.n) * 100) / 100 : 0])),
133 samples: a.n,
134 }) as Record<string, number> & { good_contact_share: Record<string, number>; samples: number }
135 const eyes_open = side(open)
136 const eyes_closed = side(closed)
137 const goodChannels = (s: typeof eyes_open) => Object.values(s.good_contact_share).filter(g => g >= 0.7).length
138 const alpha_ratio = eyes_open.alpha ? Math.round(((eyes_closed.alpha ?? 0) / eyes_open.alpha) * 100) / 100 : 0
139 const problems: string[] = []
140 if (open.n < 50 || closed.n < 50) problems.push('too few EEG samples (the stream dropped)')
141 if (goodChannels(eyes_open) < 2 || goodChannels(eyes_closed) < 2) problems.push('fewer than 2 sensors in good contact for most of a phase')
142 const valid = problems.length === 0
143 if (valid && alpha_ratio < 1.2) problems.push(`alpha barely rose with the eyes closed (${alpha_ratio}×): check the eyes were closed and the jaw relaxed`)
144 return { eyes_open, eyes_closed, alpha_ratio_closed_over_open: alpha_ratio, valid, problems }
145}
146
147const BONES: [number, number][] = [
148 [11, 12], [11, 13], [13, 15], [12, 14], [14, 16], [11, 23], [12, 24], [23, 24],
149 [23, 25], [25, 27], [27, 29], [29, 31], [27, 31], [24, 26], [26, 28], [28, 30], [30, 32], [28, 32],
150]
151
152type Run = { dir: string; stream: AsyncGenerator<unknown, unknown> & { return: (v?: unknown) => unknown } }
153let current: Run | undefined
154/** the camera the pane's picker (or the last start) chose; tests without a camera use it */
155let chosenCamera: number | undefined
156const cameras = atom({ plugin: 'move-coach', key: 'cameras' } as const, [] as { index: number; name: string }[])
157/** a camera test, as opposed to the idle preview the pane shows when it opens */
158const isTesting = (v: CoachView) => v.isRunning && v.test !== 'free'
159/** ElevenLabs settings from the plugin's options; blank key = macOS `say` */
160let voiceOpts = { key: '', voiceId: '' }
161let homeDir: string | undefined
162const bookPaths = new Map<string, string | null>()
163
164/** An illustration: your own copy in ~/.config/move-coach/book wins, then the plugin's assets; missing is fine. */
165async function bookFile($: EngineInterface, name: string): Promise<string | undefined> {
166 if (bookPaths.has(name)) return bookPaths.get(name) ?? undefined
167 if (homeDir === undefined) homeDir = (await $.process.run(['/usr/bin/printenv', 'HOME'])).stdout.trim()
168 let hit: string | null = null
169 for (const dir of [`${homeDir}/.config/move-coach/book`, `${$.plugin.root}/assets/book`]) {
170 if ((await $.process.run(['/bin/test', '-f', `${dir}/${name}`])).exitCode === 0) {
171 hit = `${dir}/${name}`
172 break
173 }
174 }
175 bookPaths.set(name, hit)
176 return hit ?? undefined
177}
178let eegRun: Run | undefined
179/** band power summed over the running camera test, for the result message */
180let eegAcc: { sums: Record<string, number>; n: number } = { sums: {}, n: 0 }
181/** when the last EEG sample arrived, to tell a live stream from a stalled one */
182let lastEegAt = 0
183type Timer = ReturnType<EngineInterface['clock']['after']>
184type BaselineRun = { phase: 'settle' | 'open' | 'switch' | 'closed'; open: PhaseAcc; closed: PhaseAcc; timers: Timer[]; seconds: number; audio: string }
185let baselineRun: BaselineRun | undefined
186/** the latest valid baseline's eyes-open bands, the reference for each test's EEG */
187let lastBaseline: { at: string; eyes_open: Record<string, number> } | undefined
188/** the current person's store-key suffix */
189let personId = ''
190
191/** "Alex Smith" → "alex-smith": the store-key form of a name. */
192export function personIdOf(name: string): string {
193 return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
194}
195
196/** A store key scoped to the current person. */
197const pkey = (base: 'scores' | 'history' | 'baseline' | 'baselines') => `${base}:${personId}`
198
199type People = Record<string, { name: string; createdAt: string }>
200
201/** Switches the profile: loads that person's scores and baseline. New names start empty. */
202async function switchPerson($: EngineInterface, name: string): Promise<{ name: string; isNew: boolean; scores: Scores }> {
203 const id = personIdOf(name)
204 const people = ((await $.store.get('people')) as People | undefined) ?? {}
205 const isNew = !people[id]
206 const display = isNew ? name.trim() : people[id]!.name
207 if (isNew) await $.store.set('people', { ...people, [id]: { name: display, createdAt: new Date().toISOString() } })
208 personId = id
209 await $.store.set('person', display)
210 const saved = ((await $.store.get(pkey('scores'))) as Scores | undefined) ?? {}
211 await update($, scores, () => saved)
212 await update($, person, () => display)
213 lastBaseline = ((await $.store.get(pkey('baseline'))) as typeof lastBaseline) ?? undefined
214 return { name: display, isNew, scores: saved }
215}
216
217/** Moves the pre-profile results (unscoped keys) to the legacy person, once. */
218async function migrateLegacy($: EngineInterface): Promise<void> {
219 if (await $.store.get('people')) return
220 const id = personIdOf(LEGACY_PERSON)
221 for (const base of ['scores', 'history', 'baseline', 'baselines'] as const) {
222 const v = await $.store.get(base)
223 if (v !== undefined) await $.store.set(`${base}:${id}`, v)
224 }
225 await $.store.set('people', { [id]: { name: LEGACY_PERSON, createdAt: new Date().toISOString() } })
226 await $.store.set('person', LEGACY_PERSON)
227}
228
229/** Person switch from the tool or command: refuses mid-test, else switches and describes the profile. */
230async function changePerson($: EngineInterface, name: string | undefined): Promise<string> {
231 const people = ((await $.store.get('people')) as People | undefined) ?? {}
232 if (!name?.trim() || !personIdOf(name)) {
233 const list = Object.values(people).map(p => p.name).join(', ') || 'none'
234 return `Testing ${await read($, person)}. People with results: ${list}. Give a name to switch.`
235 }
236 if (isTesting(await read($, view)) || baselineRun) return 'Not switched: a test or baseline is running. Stop it first.'
237 const r = await switchPerson($, name)
238 return r.isNew ? `New profile: ${r.name}. Their Move Score starts empty, and they need their own EEG baseline.` : `Switched to ${r.name}. ${scoreLine(r.name, r.scores)}.${lastBaseline ? '' : ' No EEG baseline yet.'}`
239}
240
241const USAGE = [
242 '/move-coach quick Jane 5-minute assessment (3 tests) for Jane',
243 '/move-coach full Jane all 7 tests for Jane',
244 '/move-coach person Jane switch the profile to Jane (also: name, profile); no name lists everyone',
245 '/move-coach reset clear the current person\'s Move Score',
246 '/move-coach squat [camera] one test: sit_and_rise, couch, airport_scanner, shoulder_rotation, squat, solec, old_man',
247 '/move-coach camera preview; stop · cameras · eeg [stop] · baseline [seconds]',
248].join('\n')
249
250/** "Alex: 28/60 (3 of 6 tests)" or "Alex: no tests yet". */
251function scoreLine(name: string, s: Scores): string {
252 const t = totalOf(s)
253 return t.done ? `${name}: Move Score ${t.points}/${t.max} (${t.done} of ${SCORED.length} tests)` : `${name}: no tests yet`
254}
255let uvPath: string | undefined
256const imageCache = new Map<string, string>()
257
258/** Camera frame plus the green pose skeleton, as one SVG (remote surfaces). */
259export function cameraSvg(v: Pick<CoachView, 'w' | 'h' | 'jpeg' | 'lm'>): string {
260 const { w, h } = v
261 const pt = (i: number) => v.lm[i]
262 const ok = (i: number) => pt(i) !== undefined && pt(i)![2]! > 0.4
263 const lines = BONES.filter(([a, b]) => ok(a) && ok(b))
264 .map(([a, b]) => `<line x1="${(pt(a)![0]! * w).toFixed(1)}" y1="${(pt(a)![1]! * h).toFixed(1)}" x2="${(pt(b)![0]! * w).toFixed(1)}" y2="${(pt(b)![1]! * h).toFixed(1)}"/>`)
265 .join('')
266 const dots = v.lm
267 .map((p, i) => (ok(i) && i > 10 ? `<circle cx="${(p[0]! * w).toFixed(1)}" cy="${(p[1]! * h).toFixed(1)}" r="4"/>` : ''))
268 .join('')
269 const img = v.jpeg && v.jpeg.length < 120_000 ? `<image href="data:image/jpeg;base64,${v.jpeg}" width="${w}" height="${h}"/>` : ''
270 return (
271 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">` +
272 `<rect width="${w}" height="${h}" fill="#111"/>${img}` +
273 `<g stroke="#39ff5a" stroke-width="3" stroke-linecap="round">${lines}</g>` +
274 `<g fill="#00ff3c" stroke="#063" stroke-width="1">${dots}</g></svg>`
275 )
276}
277
278function bookSvg(b64: string): string {
279 return (
280 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 520" width="260" height="260">` +
281 `<rect width="520" height="520" fill="#fff"/>` +
282 `<image href="data:image/jpeg;base64,${b64}" width="520" height="520" preserveAspectRatio="xMidYMid meet"/></svg>`
283 )
284}
285
286/** One line of the helper's stdout, applied to the view. */
287export function applyLine(v: CoachView, msg: Record<string, unknown>): CoachView {
288 switch (msg.type) {
289 case 'frame':
290 return {
291 ...v,
292 gen: Number(msg.gen),
293 w: Number(msg.w),
294 h: Number(msg.h),
295 jpeg: String(msg.jpeg ?? ''),
296 png: String(msg.png ?? ''),
297 cells: String(msg.cells ?? ''),
298 cols: Number(msg.cols ?? 0),
299 rows: Number(msg.rows ?? 0),
300 lm: (msg.lm as number[][]) ?? [],
301 phase: String(msg.phase ?? v.phase),
302 instruction: String(msg.instruction ?? v.instruction),
303 cue: String(msg.cue ?? v.cue),
304 metrics: (msg.metrics as Record<string, unknown>) ?? {},
305 elapsed: Number(msg.elapsed ?? 0),
306 }
307 case 'status':
308 return { ...v, status: String(msg.message ?? '') }
309 case 'error':
310 return { ...v, error: String(msg.message ?? ''), isRunning: false }
311 case 'result':
312 return { ...v, result: JSON.stringify(msg, null, 2), phase: 'done', isRunning: false }
313 default:
314 return v
315 }
316}
317
318async function findUv($: EngineInterface): Promise<string> {
319 if (uvPath) return uvPath
320 const found = await $.process.run(['/bin/zsh', '-lc', 'command -v uv'], { timeoutMs: 10_000 })
321 uvPath = found.exitCode === 0 && found.stdout.trim() ? found.stdout.trim() : 'uv'
322 return uvPath
323}
324
325const CAMERA_ALIASES: Record<string, string> = { logitech: 'c920|logitech|webcam', iphone: 'iphone', mac: 'macbook pro camera', macbook: 'macbook pro camera' }
326
327/** Video devices as macOS lists them, in the index order OpenCV uses; also refreshes the pane's picker, since plugging a camera in renumbers them. */
328async function listCameras($: EngineInterface): Promise<{ index: number; name: string }[]> {
329 const listed = await $.process.run(['/bin/zsh', '-lc', 'ffmpeg -hide_banner -f avfoundation -list_devices true -i "" 2>&1'], { timeoutMs: 15_000 })
330 const video = listed.stdout.split('AVFoundation audio devices')[0] ?? ''
331 const cams = [...video.matchAll(/\[(\d+)\] (.+)/g)].map(m => ({ index: Number(m[1]), name: m[2]!.trim() })).filter(c => !/Capture screen/.test(c.name))
332 if (cams.length) await update($, cameras, () => cams)
333 return cams
334}
335
336/** A camera number, or a name ("logitech", "iphone", "c920") matched against the device list. */
337async function resolveCamera($: EngineInterface, camera: string | number | undefined): Promise<number> {
338 if (camera === undefined || camera === '') return chosenCamera ?? (((await $.store.get('camera')) as number | undefined) ?? 0)
339 if (/^\d+$/.test(String(camera))) return Number(camera)
340 const want = String(camera).toLowerCase()
341 const pattern = new RegExp(CAMERA_ALIASES[want] ?? want.replace(/[^a-z0-9 ]/g, ''), 'i')
342 const hit = (await listCameras($)).find(c => pattern.test(c.name) && !/desk view/i.test(c.name))
343 return hit ? hit.index : 0
344}
345
346/** Brain waves as an SVG: one trace per channel, plus relative band power bars. */
347export function eegSvg(v: EegView): string {
348 const W = 480, rowH = 34, bandsH = 46
349 const H = v.channels.length * rowH + bandsH
350 const traces = v.raw
351 .map((ch, i) => {
352 if (!ch.length) return ''
353 const mid = i * rowH + rowH / 2
354 const scale = (rowH / 2 - 2) / 100 // ±100 µV fills the row
355 const pts = ch.map((y, j) => `${((j / (ch.length - 1)) * (W - 52) + 50).toFixed(1)},${(mid - Math.max(-100, Math.min(100, y)) * scale).toFixed(1)}`).join(' ')
356 const q = v.quality[i] ?? ''
357 const qc = q === 'good' ? '#1fbf75' : q === 'flat' ? '#999' : '#ef5b5b'
358 return (
359 `<text x="2" y="${mid + 4}" font-size="11" fill="#bbb" font-family="monospace">${v.channels[i]}</text>` +
360 `<circle cx="42" cy="${mid}" r="3.5" fill="${qc}"/>` +
361 `<polyline points="${pts}" fill="none" stroke="${TRACE_COLORS[i % 4]}" stroke-width="1.3"/>`
362 )
363 })
364 .join('')
365 const y0 = v.channels.length * rowH + 6
366 const bw = (W - 20) / BAND_ORDER.length
367 const bars = BAND_ORDER.map((b, i) => {
368 const frac = Math.max(0, Math.min(1, v.bands[b] ?? 0))
369 const x = 10 + i * bw
370 return (
371 `<rect x="${x}" y="${y0}" width="${bw - 8}" height="12" rx="3" fill="#333"/>` +
372 `<rect x="${x}" y="${y0}" width="${((bw - 8) * frac).toFixed(1)}" height="12" rx="3" fill="${BAND_COLORS[b]}"/>` +
373 `<text x="${x}" y="${y0 + 28}" font-size="11" fill="#ccc" font-family="sans-serif">${b} ${Math.round(frac * 100)}%</text>`
374 )
375 }).join('')
376 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><rect width="${W}" height="${H}" fill="#111"/>${traces}${bars}</svg>`
377}
378
379const SPARK = '▁▂▃▄▅▆▇█'
380/** One channel as a terminal sparkline, ±100 µV. */
381export function sparkline(ch: number[], width = 48): string {
382 if (!ch.length) return ''
383 const step = ch.length / width
384 return Array.from({ length: width }, (_, i) => {
385 const y = ch[Math.floor(i * step)] ?? 0
386 return SPARK[Math.max(0, Math.min(7, Math.round(((Math.max(-100, Math.min(100, y)) + 100) / 200) * 7)))]
387 }).join('')
388}
389
390/** A test's band power next to the latest valid baseline (eyes open), as ratios. */
391function vsBaseline(brain: Record<string, number>): Record<string, unknown> {
392 if (!lastBaseline) return { eeg_baseline: 'none: run action "baseline" for a resting reference' }
393 const ref = lastBaseline.eyes_open
394 return {
395 eeg_baseline_eyes_open: ref,
396 eeg_baseline_at: lastBaseline.at,
397 eeg_vs_baseline: Object.fromEntries(BAND_ORDER.map(b => [b, ref[b] ? Math.round(((brain[b] ?? 0) / ref[b]!) * 100) / 100 : null])),
398 }
399}
400
401const BASELINE_PHASE = { settle: 'get still', open: 'eyes open', switch: 'close your eyes', closed: 'eyes closed' } as const
402
403/** The pane's baseline line: the phase and seconds left while it runs, the outcome after. */
404export function baselineLine(b: NonNullable<EegView['baseline']>, now = Date.now()): string {
405 if (b.phase === 'done' || b.phase === 'cancelled') return `Baseline ${b.summary}`
406 return `Baseline · ${BASELINE_PHASE[b.phase]} · ${Math.max(0, Math.ceil((b.endsAt - now) / 1000))} s`
407}
408
409function eegSummary(): Record<string, number> | undefined {
410 if (!eegAcc.n) return undefined
411 return Object.fromEntries(BAND_ORDER.map(b => [b, Math.round(((eegAcc.sums[b] ?? 0) / eegAcc.n) * 1000) / 1000]))
412}
413
414async function stopEeg($: EngineInterface): Promise<void> {
415 if (!eegRun) return
416 const run = eegRun
417 await $.fs.write(`${run.dir}/control.json`, JSON.stringify({ cmd: 'stop', at: Date.now() }))
418 $.clock.after(4000, () => {
419 if (eegRun === run) void run.stream.return(undefined)
420 })
421}
422
423/** Why the EEG can't be used right now, or '' when a Muse is connected and samples are flowing. */
424async function eegNotReady($: EngineInterface): Promise<string> {
425 const ev = await read($, eeg)
426 if (!eegRun || !ev.isRunning) return 'EEG is off. Start it first with action "eeg_start" (or /move-coach eeg), turn the Muse on, and wait for "connected" in the pane.'
427 if (ev.error) return `EEG has an error: ${ev.error}`
428 if (!ev.device || Date.now() - lastEegAt > 3000) return `The Muse isn't streaming yet (${ev.status || 'no samples'}). Wait until the pane shows the brain waves moving, then try again.`
429 return ''
430}
431
432/** The Mac's default output device, so the person knows where the spoken cues go. */
433async function audioOutput($: EngineInterface): Promise<string> {
434 const out = await $.process.run(['/bin/zsh', '-lc', "system_profiler SPAudioDataType 2>/dev/null | grep -B8 'Default Output Device: Yes' | grep -E '^ {8}[^ ]' | head -1"], { timeoutMs: 15_000 })
435 return out.stdout.trim().replace(/:$/, '') || 'unknown'
436}
437
438function speak($: EngineInterface, text: string): void {
439 void $.process.run(['/usr/bin/say', '-r', '150', text], { timeoutMs: 30_000 })
440}
441
442async function setBaseline($: EngineInterface, b: EegView['baseline']): Promise<void> {
443 await update($, eeg, v => ({ ...v, baseline: b }))
444}
445
446async function cancelBaseline($: EngineInterface, reason: string): Promise<boolean> {
447 if (!baselineRun) return false
448 for (const t of baselineRun.timers) t.cancel()
449 baselineRun = undefined
450 speak($, 'Baseline cancelled.')
451 await setBaseline($, { phase: 'cancelled', endsAt: Date.now(), summary: `cancelled: ${reason}` })
452 return true
453}
454
455/**
456 * Resting EEG reference: settle 5 s, eyes open for `seconds`, 3 s to close the eyes,
457 * eyes closed for `seconds`. Spoken cues; the result arrives as a message.
458 * Only runs with the Muse connected and streaming, and no camera test running.
459 */
460async function startBaseline($: EngineInterface, seconds = 30): Promise<string> {
461 const notReady = await eegNotReady($)
462 if (notReady) return `Baseline not started. ${notReady}`
463 if (baselineRun) return 'A baseline is already running.'
464 if (isTesting(await read($, view))) return 'Baseline not started: a camera test is running. Stop it first, or wait until it ends.'
465 const s = Math.max(10, Math.min(120, Math.round(seconds)))
466 const audio = await audioOutput($)
467 const run: BaselineRun = { phase: 'settle', open: emptyAcc(), closed: emptyAcc(), timers: [], seconds: s, audio }
468 baselineRun = run
469 const at = (ms: number, fn: () => Promise<void>) => run.timers.push($.clock.after(ms, () => void (baselineRun === run && fn())))
470 const t0 = Date.now()
471 await setBaseline($, { phase: 'settle', endsAt: t0 + 5000, summary: '' })
472 await openPane($)
473 speak($, `Baseline. Sit still and relax your jaw. Eyes open, soft gaze, for ${s} seconds.`)
474 at(5000, async () => {
475 run.phase = 'open'
476 await setBaseline($, { phase: 'open', endsAt: Date.now() + s * 1000, summary: '' })
477 })
478 at(5000 + s * 1000, async () => {
479 run.phase = 'switch'
480 speak($, 'Now close your eyes and stay still.')
481 await setBaseline($, { phase: 'switch', endsAt: Date.now() + 3000, summary: '' })
482 })
483 at(8000 + s * 1000, async () => {
484 run.phase = 'closed'
485 await setBaseline($, { phase: 'closed', endsAt: Date.now() + s * 1000, summary: '' })
486 })
487 at(8000 + 2 * s * 1000, async () => {
488 baselineRun = undefined
489 speak($, 'Baseline done. Open your eyes.')
490 const result = summarizeBaseline(run.open, run.closed, (await read($, eeg)).channels)
491 const when = new Date().toISOString()
492 if (result.valid) {
493 lastBaseline = { at: when, eyes_open: Object.fromEntries(BAND_ORDER.map(b => [b, result.eyes_open[b] ?? 0])) }
494 await $.store.set(pkey('baseline'), lastBaseline)
495 }
496 const history = ((await $.store.get(pkey('baselines'))) as unknown[] | undefined) ?? []
497 await $.store.set(pkey('baselines'), [...history, { at: when, ...result }].slice(-100))
498 await setBaseline($, {
499 phase: 'done',
500 endsAt: Date.now(),
501 summary: `${result.valid ? '✓ valid' : '✗ not usable'} · alpha ×${result.alpha_ratio_closed_over_open} with eyes closed${result.problems.length ? ` · ${result.problems[0]}` : ''}`,
502 })
503 await $.prompt.submit({
504 text:
505 `Move Coach finished the EEG baseline (${s} s eyes open, ${s} s eyes closed; cues spoken on "${run.audio}"):\n\n\`\`\`json\n${JSON.stringify({ type: 'baseline', person: await read($, person), at: when, ...result }, null, 2)}\n\`\`\`\n\n` +
506 'Report whether it is valid, what the alpha change with the eyes closed shows, and which sensors had poor contact. ' +
507 'Log it as step 0 (baseline-eyes-open and baseline-eyes-closed rows) in built-to-move-eeg-log.csv. Later test results compare against it (alpha_vs_baseline).',
508 })
509 })
510 return (
511 `Baseline started: 5 s to settle, ${s} s eyes open, then ${s} s eyes closed (about ${Math.round((8 + 2 * s) / 5) * 5} s in all). ` +
512 `Cues are spoken through "${audio}"; if the person can't hear that device, stop with action "stop" and fix the audio first. ` +
513 'Tell the person to sit still with a relaxed jaw, then end your turn; the result arrives as a message.'
514 )
515}
516
517/** Optional: stream a Muse headband through muse-lsl into the pane. */
518async function startEeg($: EngineInterface, muse?: string): Promise<void> {
519 if (eegRun && (await read($, eeg)).isRunning && !muse) {
520 await openPane($)
521 return
522 }
523 await stopEeg($)
524 const uv = await findUv($)
525 const dir = `/tmp/move-coach/eeg-${Date.now()}`
526 await $.process.run(['/bin/mkdir', '-p', dir])
527 // same app wrapper as the camera, so macOS asks about (and remembers) Bluetooth for it
528 const argv = [`${$.plugin.root}/helper/launch.sh`, dir, uv, 'run', '--script', `${$.plugin.root}/helper/eeg_stream.py`, '--out-dir', dir, ...(muse ? ['--name', muse] : [])]
529 await update($, eeg, () => ({ ...EEG_EMPTY, isRunning: true, status: 'starting EEG…' }))
530 await openPane($)
531 const stream = $.process.spawn({ argv }) as unknown as Run['stream']
532 const run: Run = { dir, stream }
533 eegRun = run
534 void (async () => {
535 let buf = ''
536 let errTail = ''
537 try {
538 for await (const chunk of stream as AsyncIterable<{ stream: string; text: string }>) {
539 if (chunk.stream === 'stderr') {
540 errTail = (errTail + chunk.text).slice(-600)
541 continue
542 }
543 buf += chunk.text
544 let nl: number
545 while ((nl = buf.indexOf('\n')) >= 0) {
546 const line = buf.slice(0, nl)
547 buf = buf.slice(nl + 1)
548 let msg: Record<string, unknown>
549 try {
550 msg = JSON.parse(line)
551 } catch {
552 continue
553 }
554 if (eegRun !== run) continue
555 if (msg.type === 'eeg') {
556 const bands = (msg.bands as Record<string, number>) ?? {}
557 lastEegAt = Date.now()
558 if (baselineRun?.phase === 'open') addSample(baselineRun.open, bands, (msg.quality as string[]) ?? [])
559 if (baselineRun?.phase === 'closed') addSample(baselineRun.closed, bands, (msg.quality as string[]) ?? [])
560 if (isTesting(await read($, view))) {
561 for (const b of BAND_ORDER) eegAcc.sums[b] = (eegAcc.sums[b] ?? 0) + (bands[b] ?? 0)
562 eegAcc.n++
563 }
564 await update($, eeg, v => ({
565 ...v,
566 gen: v.gen + 1,
567 device: String(msg.device ?? v.device),
568 channels: (msg.channels as string[]) ?? v.channels,
569 raw: (msg.raw as number[][]) ?? [],
570 bands,
571 quality: (msg.quality as string[]) ?? [],
572 status: v.status.startsWith('no EEG samples') || v.status.startsWith('reconnecting') ? 'streaming' : v.status,
573 }))
574 } else if (msg.type === 'status') {
575 await update($, eeg, v => ({ ...v, status: String(msg.message ?? '') }))
576 } else if (msg.type === 'error') {
577 await update($, eeg, v => ({ ...v, error: String(msg.message ?? ''), isRunning: false }))
578 }
579 }
580 }
581 } catch (err) {
582 if (eegRun === run) await update($, eeg, v => ({ ...v, error: `EEG helper failed: ${String(err)} ${errTail}`, isRunning: false }))
583 } finally {
584 if (eegRun === run) {
585 eegRun = undefined
586 await update($, eeg, v => ({ ...v, isRunning: false }))
587 await cancelBaseline($, 'the EEG stream stopped')
588 }
589 }
590 })()
591}
592
593async function stop($: EngineInterface): Promise<void> {
594 if (!current) return
595 const run = current
596 await $.fs.write(`${run.dir}/control.json`, JSON.stringify({ cmd: 'stop', at: Date.now() }))
597 // the helper emits its result and exits; force it after a grace period
598 $.clock.after(4000, () => {
599 if (current === run) void run.stream.return(undefined)
600 })
601}
602
603type Sequence = { step?: number; total?: number; next?: string }
604
605/** Opens the pane with the live camera: an idle preview starts unless a test is already running. */
606async function openPane($: EngineInterface): Promise<void> {
607 if (!current) await start($, 'free', { isStartedByTool: false })
608 else await $.ui.open({ id: PANE, title: 'Move Coach' })
609 // ffmpeg takes a moment to list devices: refresh the picker in the background
610 void listCameras($).catch(() => {})
611}
612
613/** The pane's camera picker: restarts the preview on the new camera; a running test keeps its camera. */
614async function pickCamera($: EngineInterface, index: number): Promise<void> {
615 chosenCamera = index
616 await $.store.set('camera', index)
617 const v = await read($, view)
618 if (!isTesting(v)) await start($, 'free', { camera: index, isStartedByTool: false })
619}
620
621async function start($: EngineInterface, test: TestKey, opts: { variant?: string; camera?: string | number; isStartedByTool: boolean } & Sequence): Promise<string> {
622 await stop($)
623 // the preview is only a picture: it doesn't disturb a baseline (which opens the pane, and so starts one)
624 if (test !== 'free') await cancelBaseline($, 'a camera test started')
625 const cameraIndex = await resolveCamera($, opts.camera)
626 // a name already listed afresh; a number or the remembered choice didn't, so refresh the picker's labels
627 if (typeof opts.camera !== 'string' || /^\d+$/.test(opts.camera)) void listCameras($).catch(() => {})
628 chosenCamera = cameraIndex
629 await $.store.set('camera', cameraIndex)
630 // inside tmux the terminal can't draw images, so the helper also sends half-block cells
631 const inTmux = (await $.process.run(['/usr/bin/printenv', 'TMUX'])).exitCode === 0
632 const uv = await findUv($)
633 const dir = `/tmp/move-coach/${Date.now()}`
634 await $.process.run(['/bin/mkdir', '-p', dir])
635 // the key goes in a private file the helper reads and deletes, never on the command line
636 const voiceFile = `${dir}/voice.json`
637 if (voiceOpts.key) {
638 await $.fs.write(voiceFile, JSON.stringify({ ELEVENLABS_API_KEY: voiceOpts.key, ...(voiceOpts.voiceId ? { ELEVENLABS_VOICE_ID: voiceOpts.voiceId } : {}) }))
639 await $.process.run(['/bin/chmod', '600', voiceFile])
640 }
641 // launch.sh runs the helper inside "Move Coach Camera.app": the Claude app starts this
642 // session with camera responsibility disclaimed, so a direct child can never get a grant
643 const argv = [
644 `${$.plugin.root}/helper/launch.sh`, dir,
645 uv, 'run', '--script', `${$.plugin.root}/helper/pose_coach.py`,
646 '--test', test, '--out-dir', dir, '--png',
647 '--variant', opts.variant ?? 'floor',
648 '--camera', String(cameraIndex),
649 ...(opts.next ? ['--next', opts.next] : []),
650 // the preview is silent and stays on longer; a test replaces it
651 ...(test === 'free' ? ['--mute', '--max-seconds', '1800'] : []),
652 ...(voiceOpts.key ? ['--voice-file', voiceFile] : []),
653 ...(inTmux ? ['--cells', '80x23'] : []),
654 ]
655 eegAcc = { sums: {}, n: 0 }
656 await update($, view, () => ({
657 ...EMPTY,
658 isRunning: true,
659 test,
660 title: TESTS[test].title,
661 phase: 'starting',
662 status: 'starting camera…',
663 isStartedByTool: opts.isStartedByTool,
664 step: opts.step,
665 total: opts.total,
666 nextTitle: opts.next,
667 }))
668 await $.ui.open({ id: PANE, title: 'Move Coach' })
669
670 const stream = $.process.spawn({ argv }) as unknown as Run['stream']
671 const run: Run = { dir, stream }
672 current = run
673 void (async () => {
674 let buf = ''
675 let errTail = ''
676 try {
677 for await (const chunk of stream as AsyncIterable<{ stream: string; text: string }>) {
678 if (chunk.stream === 'stderr') {
679 errTail = (errTail + chunk.text).slice(-600)
680 continue
681 }
682 buf += chunk.text
683 let nl: number
684 while ((nl = buf.indexOf('\n')) >= 0) {
685 const line = buf.slice(0, nl)
686 buf = buf.slice(nl + 1)
687 let msg: Record<string, unknown>
688 try {
689 msg = JSON.parse(line)
690 } catch {
691 continue
692 }
693 if (current !== run) continue // replaced by a newer test
694 await update($, view, v => applyLine(v, msg))
695 if (msg.type === 'result' && typeof msg.points === 'number' && !msg.stopped) {
696 const entry = { points: msg.points, label: String(msg.score_label ?? ''), at: new Date().toISOString() }
697 const next = await update($, scores, s => ({ ...s, [test]: entry }))
698 await $.store.set(pkey('scores'), next)
699 const history = ((await $.store.get(pkey('history'))) as unknown[] | undefined) ?? []
700 await $.store.set(pkey('history'), [...history, { test, ...entry }].slice(-500))
701 }
702 if (msg.type === 'result' && test !== 'free' && !msg.stopped) {
703 const brain = eegSummary()
704 msg = { ...msg, person: await read($, person) }
705 if (brain) msg = { ...msg, eeg_mean_relative_band_power: brain, eeg_samples: eegAcc.n, ...vsBaseline(brain) }
706 await $.prompt.submit({
707 text:
708 `Move Coach finished "${TESTS[test].title}" for ${await read($, person)}` +
709 (typeof msg.points === 'number' ? ` — ${msg.points}/10 (${String(msg.score_label ?? '')})` : '') +
710 `. Measured by camera:\n\n\`\`\`json\n${JSON.stringify(msg, null, 2)}\n\`\`\`\n\n` +
711 'Use the built-to-move-mobility-test skill: report the score, explain how it was computed (from "explanation"), ask me each "confirm" question, adjust the score if my answers change it, then log it.',
712 })
713 }
714 }
715 }
716 } catch (err) {
717 if (current === run) await update($, view, v => ({ ...v, error: `helper failed: ${String(err)} ${errTail}`, isRunning: false }))
718 } finally {
719 if (current === run) {
720 current = undefined
721 await update($, view, v => (v.isRunning ? { ...v, isRunning: false, error: v.error || errTail.trim().split('\n').pop() || '' } : v))
722 }
723 }
724 })()
725 return dir
726}
727
728
729export const register: Register = (on, options) => {
730 voiceOpts = { key: String(options.elevenlabs_api_key ?? '').trim(), voiceId: String(options.elevenlabs_voice_id ?? '').trim() }
731 on('session.start', async ($, e, next) => {
732 await migrateLegacy($)
733 await switchPerson($, ((await $.store.get('person')) as string | undefined) || LEGACY_PERSON)
734 await $.command.register({
735 name: 'move-coach',
736 description: 'Open the Move Coach camera pane (pose landmarks + spoken coaching)',
737 argumentHint: "[quick <name> | full <name> | person <name> | reset | <test> [camera] | stop | cameras | eeg | baseline]",
738 })
739 await $.tool.register({
740 name: TOOL,
741 description:
742 'Runs one Built to Move mobility test with the camera: opens a pane showing the live camera with MediaPipe pose landmarks and the book illustration, and coaches the person by voice. ' +
743 'action "start" returns immediately; when the test ends, the measured result arrives as a new message from the move-coach plugin. Do not poll. ' +
744 'action "snapshot" returns the latest camera frame so you can see the person. action "stop" ends the test. ' +
745 'For a guided assessment, pass step, total and next (the next test\'s name) on each start: the pane shows the progress and the coach announces what\'s next. ' +
746 'Optional EEG: action "eeg_start" (muse: a Muse name or address, default the first found) streams a Muse headband through muse-lsl and shows the brain waves live in the pane; ' +
747 'while it runs, each test result also carries the mean relative band power during the test. action "eeg_stop" ends it. ' +
748 'action "baseline" (seconds: per phase, default 30) records a resting EEG reference: 5 s to settle, eyes open, then eyes closed, with spoken cues; it only runs while the Muse is connected and streaming and no camera test runs, and refuses otherwise. ' +
749 'Its result arrives as a message (alpha with eyes closed vs open, contact per sensor, valid or not); later test results then carry eeg_vs_baseline. Run it before the first test of an EEG session. action "stop" also cancels a running baseline. ' +
750 'Profiles: scores, history and the EEG baseline belong to the person being tested. action "person" (name) switches to that person, creating an empty profile for a new name (no name: says who is current and lists everyone); ' +
751 'do it before the first test with someone new. action "reset" clears the current person\'s Move Score (history is kept). Each result carries "person". ' +
752 'action "correct" (test, points, label) overwrites a test\'s Move Score after the person\'s answers to the confirm questions change it. ' +
753 'Tests: sit_and_rise (VS1), couch (VS3), airport_scanner and shoulder_rotation (VS5), squat (VS7), solec and old_man (VS8). ' +
754 'camera takes a number or a name ("logitech", "iphone"); device numbers shift when cameras are plugged in, so prefer names.',
755 inputSchema: {
756 type: 'object',
757 properties: {
758 action: { type: 'string', enum: ['start', 'stop', 'snapshot', 'status', 'correct', 'eeg_start', 'eeg_stop', 'baseline', 'person', 'reset'] },
759 name: { type: 'string', description: 'person only: the name of the person being tested' },
760 seconds: { type: 'integer', description: 'baseline only: seconds per phase (eyes open, eyes closed), 10–120, default 30' },
761 muse: { type: 'string', description: 'eeg_start only: Muse name (e.g. "Muse-1A2B") or address; default the first found' },
762 test: { type: 'string', enum: Object.keys(TESTS) },
763 variant: { type: 'string', enum: ['floor', 'couch'], description: 'couch test only' },
764 camera: { type: ['integer', 'string'], description: 'camera number, or a name such as "logitech", "iphone", "c920"' },
765 step: { type: 'integer', description: 'guided assessment: this test\'s position (1-based)' },
766 total: { type: 'integer', description: 'guided assessment: number of tests in the sequence' },
767 next: { type: 'string', description: 'guided assessment: name of the next test, announced at the end ("" for the last)' },
768 points: { type: 'number', description: 'correct only: corrected points out of 10' },
769 label: { type: 'string', description: 'correct only: corrected score label' },
770 },
771 required: ['action'],
772 },
773 })
774 return next(e)
775 })
776
777 on('command.run', { command: 'move-coach' }, async ($, e) => {
778 const [arg, cam, ...rest] = String((e as { args?: string }).args ?? '').trim().split(/\s+/)
779 const name = [cam, ...rest].filter(Boolean).join(' ')
780 if (arg === 'person' || arg === 'name' || arg === 'profile') return { text: `Move Coach: ${await changePerson($, name)}` }
781 if (arg === 'quick' || arg === 'full' || arg === 'all') {
782 // the guided sequence is Claude's job (the built-to-move-mobility-test skill); set the person, then hand it over
783 let who = await read($, person)
784 if (name) {
785 const switched = await changePerson($, name)
786 if (switched.startsWith('Not switched')) return { text: `Move Coach: ${switched}` }
787 who = await read($, person)
788 }
789 const mode = arg === 'quick' ? 'quick' : 'all'
790 await openPane($)
791 const prompt = `Run the Move Coach ${mode === 'quick' ? 'quick assessment (Sit-and-Rise, Squat, SOLEC)' : 'full guided assessment'} for ${who} with the built-to-move-mobility-test skill (argument: ${mode} ${who}). The profile is already set to ${who}.`
792 // a prompt can't be submitted while this command is still running: hand it over just after
793 $.clock.after(300, () => void $.prompt.submit({ text: prompt }).catch(() => $.ui.toast(`Move Coach: type "/built-to-move-mobility-test ${mode} ${who}" to start.`)))
794 return { text: `Move Coach: ${mode === 'quick' ? 'quick assessment' : 'full assessment'} for ${who}. Claude takes it from here.` }
795 }
796 if (arg === 'reset') {
797 const who = await read($, person)
798 await update($, scores, () => ({}))
799 await $.store.set(pkey('scores'), {})
800 return { text: `Move Coach: ${who}'s Move Score is cleared.` }
801 }
802 if (arg === 'cameras') {
803 const cams = await listCameras($)
804 return { text: cams.length ? `Cameras (use the number or a name, e.g. /move-coach squat logitech):\n${cams.map(c => `${c.index}: ${c.name}`).join('\n')}` : 'Could not list cameras (is ffmpeg installed?).' }
805 }
806 if (arg === 'stop') {
807 await stop($)
808 await cancelBaseline($, 'stopped')
809 return { text: 'Move Coach stopped.' }
810 }
811 if (arg === 'baseline') {
812 const started = await startBaseline($, cam ? Number(cam) || 30 : 30)
813 return { text: started.startsWith('Baseline started') ? 'Move Coach: EEG baseline started. Sit still with a relaxed jaw and follow the spoken cues.' : `Move Coach: ${started}` }
814 }
815 if (arg === 'eeg') {
816 if (cam === 'stop') {
817 await stopEeg($)
818 return { text: 'Move Coach: EEG stopped.' }
819 }
820 await startEeg($, cam)
821 return { text: 'Move Coach: EEG starting. Turn your Muse on; your brain waves appear under the camera.' }
822 }
823 if (arg && !(arg in TESTS)) return { text: `Move Coach: I don't know "${arg}".\n${USAGE}` }
824 const test = (arg || 'free') as TestKey
825 await start($, test, { camera: cam, isStartedByTool: false })
826 return { text: `Move Coach: ${TESTS[test].title} started.` }
827 })
828
829 on('tool.call', { tool: `mcp__move-coach__${TOOL}` }, async ($, e) => {
830 const input = e as { action?: string; test?: string; variant?: string; camera?: string | number; points?: number; label?: string; muse?: string; seconds?: number; name?: string } & Sequence
831 const text = (t: string) => ({ result: t })
832 if (input.action === 'eeg_start') {
833 await startEeg($, input.muse)
834 return text('EEG starting: the helper attaches to a running muse-lsl stream or finds a Muse over Bluetooth (up to 45 s). Brain waves appear under the camera in the pane. Check action "status" for the connection.')
835 }
836 if (input.action === 'baseline') return text(await startBaseline($, input.seconds ?? 30))
837 if (input.action === 'person') return text(await changePerson($, input.name))
838 if (input.action === 'reset') {
839 if (isTesting(await read($, view))) return text('Not reset: a test is running.')
840 const who = await read($, person)
841 await update($, scores, () => ({}))
842 await $.store.set(pkey('scores'), {})
843 return text(`${who}'s Move Score is cleared (their history is kept).`)
844 }
845 if (input.action === 'eeg_stop') {
846 await stopEeg($)
847 return text('EEG stopping.')
848 }
849 if (input.action === 'correct') {
850 const key = input.test as TestKey | undefined
851 if (!key || !SCORED.some(t => t.key === key)) return text(`correct needs a scored test: ${SCORED.map(t => t.key).join(', ')}`)
852 if (typeof input.points !== 'number' || input.points < 0 || input.points > 10) return text('correct needs points between 0 and 10')
853 const entry = { points: input.points, label: `${input.label ?? ''} (confirmed)`.trim(), at: new Date().toISOString() }
854 const next = await update($, scores, s => ({ ...s, [key]: entry }))
855 await $.store.set(pkey('scores'), next)
856 const history = ((await $.store.get(pkey('history'))) as unknown[] | undefined) ?? []
857 await $.store.set(pkey('history'), [...history, { test: key, ...entry, corrected: true }].slice(-500))
858 const total = totalOf(next)
859 return text(`Corrected ${key} to ${input.points}/10 for ${await read($, person)}. Move Score ${total.points}/${total.max} (${total.done} of ${SCORED.length} tests).`)
860 }
861 if (input.action === 'stop') {
862 if (await cancelBaseline($, 'stopped') && !isTesting(await read($, view))) return text('Baseline cancelled; nothing was recorded.')
863 await stop($)
864 return text('Stopping. The partial result will arrive as a message.')
865 }
866 if (input.action === 'snapshot' || input.action === 'status') {
867 const v = await read($, view)
868 const ev = await read($, eeg)
869 const summary = JSON.stringify({ person: await read($, person), isRunning: v.isRunning, test: v.test, phase: v.phase, cue: v.cue, metrics: v.metrics, status: v.status, error: v.error, result: v.result ? JSON.parse(v.result) : undefined,
870 eeg: ev.isRunning || ev.error ? { isRunning: ev.isRunning, status: ev.status, error: ev.error, device: ev.device, bands: ev.bands, quality: ev.quality, baseline: ev.baseline } : undefined })
871 if (input.action === 'snapshot' && v.jpeg) {
872 return { result: [{ type: 'text', text: summary }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: v.jpeg } }] }
873 }
874 return text(summary)
875 }
876 const test = input.test as TestKey | undefined
877 if (!test || !(test in TESTS)) return text(`Unknown test. Use one of: ${Object.keys(TESTS).join(', ')}`)
878 await start($, test, { variant: input.variant, camera: input.camera, isStartedByTool: true, step: input.step, total: input.total, next: input.next })
879 return text(
880 `Started ${TESTS[test].title}. The Move Coach pane shows the book illustration and the live camera with pose landmarks, and the coach is speaking. ` +
881 'Tell the person briefly how to set up, then end your turn; the result arrives as a new message when the test finishes.',
882 )
883 })
884
885 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
886 const els = $.ui.resolve(e) as any
887 const { Box, Text, Button } = els
888 const v = await read($, view)
889 const camList = await read($, cameras)
890 const meta = TESTS[v.test]
891 const isTerminal = e.surface === 'terminal'
892
893 const pictures = []
894 for (const img of meta.images.slice(0, isTerminal ? 2 : 3)) {
895 if (isTerminal && v.cells) {
896 // inside tmux: the pre-rendered half-block version of the illustration
897 let art = imageCache.get(`${img.file}.cells`)
898 if (!art) {
899 const path = await bookFile($, `${img.file}.cells.json`)
900 if (!path) continue
901 art = await $.fs.read(path)
902 imageCache.set(`${img.file}.cells`, art)
903 }
904 const { cols, rows, cells } = JSON.parse(art) as { cols: number; rows: number; cells: string }
905 pictures.push(
906 <Box flexDirection="column" width={32}>
907 <els.Raster key={`book-${img.file}`} columns={cols} rows={rows} cells={cells} />
908 <Text dimColor>{img.caption}</Text>
909 </Box>,
910 )
911 } else if (isTerminal) {
912 const path = await bookFile($, `${img.file}.png`)
913 if (!path) continue
914 pictures.push(
915 <Box flexDirection="column" width={32}>
916 <els.Image source={{ file: path, format: 'png' }} columns={30} rows={14} alt={img.caption} />
917 <Text dimColor>{img.caption}</Text>
918 </Box>,
919 )
920 } else if (els.Svg) {
921 let b64 = imageCache.get(img.file)
922 if (!b64) {
923 const path = await bookFile($, `${img.file}.jpg`)
924 if (!path) continue
925 const bytes = await $.fs.read(path, { as: 'bytes' })
926 b64 = (bytes as { base64: string }).base64
927 imageCache.set(img.file, b64)
928 }
929 pictures.push(
930 <Box flexDirection="column" width={30}>
931 <els.Svg source={bookSvg(b64)} alt={img.caption} width={200} height={200} />
932 <Text dimColor>{img.caption}</Text>
933 </Box>,
934 )
935 }
936 }
937
938 let camera
939 if (isTerminal) {
940 camera = v.cells ? (
941 <els.Raster key="cam" columns={v.cols} rows={v.rows} cells={v.cells} />
942 ) : v.png ? (
943 <els.Image key="cam" source={{ file: v.png, format: 'png', generation: v.gen }} columns={64} rows={20} alt="camera with pose landmarks" />
944 ) : (
945 <Text dimColor>{v.isRunning ? 'waiting for the camera…' : 'camera off'}</Text>
946 )
947 } else if (els.Svg) {
948 camera = <els.Svg source={cameraSvg(v)} alt="camera with pose landmarks" width={480} />
949 } else {
950 camera = <Text dimColor>This surface can't draw the camera.</Text>
951 }
952
953 const metrics = Object.entries(v.metrics)
954 .map(([k, x]) => `${k}: ${typeof x === 'object' ? JSON.stringify(x) : String(x)}`)
955 .join(' · ')
956
957 const ev = await read($, eeg)
958 const board = await read($, scores)
959 const who = await read($, person)
960 const total = totalOf(board)
961 let last: { points?: number; score_label?: string; explanation?: string; stopped?: boolean } = {}
962 try {
963 last = v.result ? JSON.parse(v.result) : {}
964 } catch {}
965
966 return (
967 <Box flexDirection="column">
968 <Box borderStyle="round" borderColor="green" paddingX={1} flexDirection="column">
969 <Text bold color="green">
970 MOVE SCORE{who ? ` · ${who}` : ''} {total.points} / {total.max} ({total.done} of {SCORED.length} tests)
971 </Text>
972 {SCORED.map(t => (
973 <Text dimColor={board[t.key] === undefined}>
974 {t.short.padEnd(13)} {board[t.key] ? `${bar(board[t.key]!.points)} ${String(board[t.key]!.points).padStart(2)}/10 ${board[t.key]!.label}` : '▱▱▱▱▱▱▱▱▱▱ – not tested'}
975 </Text>
976 ))}
977 </Box>
978 {!v.isRunning && typeof last.points === 'number' && (
979 <Box borderStyle="double" borderColor="yellow" paddingX={1} flexDirection="column">
980 <Text bold color="yellow">
981 ✓ {meta.title}: {last.score_label} → {last.points}/10 points
982 </Text>
983 {last.explanation && <Text>How it was scored: {last.explanation}</Text>}
984 </Box>
985 )}
986 {v.step && v.total ? (
987 <Text bold color="cyan">
988 Test {v.step} of {v.total}: {v.title}{v.nextTitle ? ` · next: ${v.nextTitle}` : ' · last test'}
989 </Text>
990 ) : (
991 <Text bold>{v.title}</Text>
992 )}
993 {v.instruction && <Text>{v.instruction}</Text>}
994 {v.isRunning && v.cue && <Text color="green">🗣 {v.cue}</Text>}
995 <Box flexDirection="row" gap={2}>
996 {camera}
997 {pictures.length > 0 && <Box flexDirection="column">{pictures}</Box>}
998 </Box>
999 {metrics && <Text dimColor>{metrics}</Text>}
1000 {(ev.isRunning || ev.error || ev.raw.length > 0) && (
1001 <Box flexDirection="column">
1002 <Text bold color="magenta">
1003 🧠 Brain waves{ev.device ? ` · ${ev.device}` : ''}{ev.status ? ` · ${ev.status}` : ''}
1004 </Text>
1005 {ev.raw.length > 0 &&
1006 (!isTerminal && els.Svg ? (
1007 <els.Svg source={eegSvg(ev)} alt="EEG traces per channel and relative band power" width={480} />
1008 ) : (
1009 <Box flexDirection="column">
1010 {ev.channels.map((c, i) => (
1011 <Text>
1012 {c.padEnd(5)} {sparkline(ev.raw[i] ?? [])} {ev.quality[i] ?? ''}
1013 </Text>
1014 ))}
1015 <Text>{BAND_ORDER.map(b => `${b} ${Math.round((ev.bands[b] ?? 0) * 100)}%`).join(' ')}</Text>
1016 </Box>
1017 ))}
1018 {ev.error && <Text color="red">{ev.error}</Text>}
1019 {ev.baseline && <Text color={ev.baseline.phase === 'done' ? 'green' : ev.baseline.phase === 'cancelled' ? 'yellow' : 'cyan'}>{baselineLine(ev.baseline)}</Text>}
1020 </Box>
1021 )}
1022 <Text dimColor>
1023 {v.isRunning ? `● ${v.phase} · ${v.elapsed}s` : v.test === 'free' ? '○ camera off' : v.phase === 'done' ? (last.stopped ? '■ stopped. The camera is off; nothing was scored.' : '✓ done') : '○ idle'}
1024 {v.status ? ` · ${v.status}` : ''}
1025 </Text>
1026 {v.error && <Text color="red">{v.error}</Text>}
1027 {!v.isRunning && (last as { confirm?: string[] }).confirm?.length ? (
1028 <Box flexDirection="column">
1029 <Text bold>Questions to confirm the score (answer in the chat):</Text>
1030 {(last as { confirm?: string[] }).confirm!.map(q => (
1031 <Text>• {q}</Text>
1032 ))}
1033 </Box>
1034 ) : null}
1035 <Box flexDirection="row" gap={2}>
1036 {els.Select && camList.length > 0 && (
1037 <els.Select
1038 // keyed on the device list so a renumbered list remounts the picker instead of keeping stale labels
1039 key={`camera:${camList.map(c => c.name).join('|')}`}
1040 label="Camera"
1041 options={camList.map(c => ({ value: String(c.index), label: c.name }))}
1042 value={String(chosenCamera ?? 0)}
1043 onSelect={async (value: string) => pickCamera($, Number(value))}
1044 />
1045 )}
1046 {v.isRunning && <Button key="stop" label="Stop" onPress={async () => stop($)} />}
1047 {ev.isRunning && !isTesting(v) && !baselineRun && <Button key="eeg-baseline" label="EEG baseline" onPress={async () => void (await startBaseline($))} />}
1048 {ev.isRunning ? (
1049 <Button key="eeg-stop" label="Stop EEG" onPress={async () => stopEeg($)} />
1050 ) : (
1051 <Button key="eeg-start" label="Connect Muse EEG" onPress={async () => startEeg($)} />
1052 )}
1053 {v.isRunning && v.test === 'old_man' && (
1054 <Button key="next" label="Done with this side" onPress={async () => $.fs.write(`${current?.dir}/control.json`, JSON.stringify({ cmd: 'next', at: Date.now() }))} />
1055 )}
1056 </Box>
1057 </Box>
1058 )
1059 })
1060}
1061types/index.d.ts 76 lines1export type TestKey =
2 | 'free'
3 | 'sit_and_rise'
4 | 'couch'
5 | 'airport_scanner'
6 | 'shoulder_rotation'
7 | 'squat'
8 | 'solec'
9 | 'old_man'
10
11export type CoachView = {
12 isRunning: boolean
13 test: TestKey
14 title: string
15 phase: string
16 instruction: string
17 cue: string
18 status: string
19 error: string
20 metrics: Record<string, unknown>
21 elapsed: number
22 gen: number
23 w: number
24 h: number
25 jpeg: string
26 png: string
27 cells: string
28 cols: number
29 rows: number
30 lm: number[][]
31 result: string
32 isStartedByTool: boolean
33 /** guided assessment progress, when the test is one step of a sequence */
34 step?: number
35 total?: number
36 nextTitle?: string
37}
38
39/** The optional Muse EEG stream (muse-lsl), drawn under the camera. */
40export type EegView = {
41 isRunning: boolean
42 status: string
43 error: string
44 device: string
45 channels: string[]
46 /** the last ~4 s per channel, µV, mean removed */
47 raw: number[][]
48 /** relative band power, 0–1, over the last second */
49 bands: Record<string, number>
50 /** per channel: good, flat (no skin contact) or noisy (motion, loose sensor) */
51 quality: string[]
52 gen: number
53 /** the eyes-open / eyes-closed baseline, while it runs and after it ends */
54 baseline?: BaselineView
55}
56
57export type BaselineView = {
58 /** settle → open → switch → closed → done (or cancelled) */
59 phase: 'settle' | 'open' | 'switch' | 'closed' | 'done' | 'cancelled'
60 /** Date.now() when the current phase ends */
61 endsAt: number
62 /** one-line outcome once done or cancelled */
63 summary: string
64}
65
66/** A test's latest book score. */
67export type ScoreEntry = { points: number; label: string; at: string }
68
69export type Scores = Partial<Record<TestKey, ScoreEntry>>
70
71declare module 'claude-code' {
72 interface PluginState {
73 'move-coach': { view: CoachView; scores: Scores; eeg: EegView; person: string; cameras: { index: number; name: string }[] }
74 }
75}
76