SLOPSHOPPER

move-coach

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

newpaneguardcommandtoasttool
v0.2.0MITupdated 2026-10-06natea/move-coach/plugins/move-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · move-coach
│ ┃ Move Coach ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ MOVE SCORE · Nate 0 / 60 (0 of 6 │ ⏺ Read(src/auth.ts) │ ┃ │ tests) │ ⎿ Read 6 lines │ ┃ │ Sit-and-Rise ▱▱▱▱▱▱▱▱▱▱ – not tested │ ⏺ Update(src/auth.ts) │ ┃ │ Couch ▱▱▱▱▱▱▱▱▱▱ – not tested │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Arms Raise ▱▱▱▱▱▱▱▱▱▱ – not tested │ ⏺ Bash(bun test) │ ┃ │ Squat ▱▱▱▱▱▱▱▱▱▱ – not tested │ ⎿ 3 pass, 1 fail │ ┃ │ SOLEC ▱▱▱▱▱▱▱▱▱▱ – not tested │ │ ┃ │ Old Man ▱▱▱▱▱▱▱▱▱▱ – not tested │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ Camera preview ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ camera off › /move-coach │ ┃ ⎿ move-coach: Move Coach: Camera preview started. │ ┃ ○ camera off · starting camera… │ ┃ │ ┃ [ Connect Muse EEG ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Move Coach
╭──────────────────────────────────────────────────────────╮ │ MOVE SCORE · Nate 0 / 60 (0 of 6 tests) │ │ Sit-and-Rise ▱▱▱▱▱▱▱▱▱▱ – not tested │ │ Couch ▱▱▱▱▱▱▱▱▱▱ – not tested │ │ Arms Raise ▱▱▱▱▱▱▱▱▱▱ – not tested │ │ Squat ▱▱▱▱▱▱▱▱▱▱ – not tested │ │ SOLEC ▱▱▱▱▱▱▱▱▱▱ – not tested │ │ Old Man ▱▱▱▱▱▱▱▱▱▱ – not tested │ ╰──────────────────────────────────────────────────────────╯ Camera preview camera off ○ camera off · starting camera… [ Connect Muse EEG ]
README

Move Coach

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.
  • Tool mcp__move-coach__camera_test lets Claude run a test. The built-to-move-mobility-test skill uses it.
  • Tests: sit_and_rise, couch, airport_scanner, shoulder_rotation, squat, solec, old_man.

Pieces

  • 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.

Voice

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.

Requirements

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.

Camera permission

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.

Optional: brain waves from a Muse headband

/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.

Source 2 files
hooks/register.tsx 1061 lines
1import { 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}
1061
types/index.d.ts 76 lines
1export 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