SLOPSHOPPER

bespunky-voice

Hear Claude's questions and answer by voice, away from the screen. Questions are asked the way a person asks them (`/speak say`, or automatically with `/speak…

newbandguardtoastprocesstimer
v0.11.0no licenseupdated 2026-10-06BeSpunky/claude-toolkit/plugins/voice
A shopper browsing a rack in a slop shop
README

claude-toolkit

BeSpunky's one place for Claude Code skills, subagents, and commands. Develop them here once, install them into any project, and upgrade everywhere with a single update.

This is a Claude Code plugin marketplace (a git repo). It currently ships these plugins:

PluginProvidesPurpose
bespunkyskills index, tips · hookThe toolkit's front door. /bespunky:index is a self-maintaining catalog: it lists every installed toolkit skill — grouped by plugin, each with its /plugin:skill invocation and a one-line "use when" — by reading the live available-skills set in the session, so it can never go stale. Every plugin shares the bespunky- prefix, so typing /bespunky in the slash menu filters the autocomplete to the whole toolkit at once. Toolkit tips: a SessionStart hook slips a small rotating handful of toolkit tips into Claude Code's own "working…" spinner, mixed in with its built-in ones. They sit outside the conversation (the model never sees them), there's nothing to dismiss, and they only cover plugins installed for the project. Each plugin owns its tips in a tips.txt at its root. A plugin can't set spinner tips itself, so the hook keeps them in the user's ~/.claude/settings.json (spinnerTipsOverride.tips), touching only the entries it wrote. /bespunky:tips off takes them out and keeps them out, on brings them back, and list shows them all. There is no uninstall event, so run off before uninstalling if you want them gone.
browser-automationskills playwright, shared-browserTwo ways to drive a real browser. playwright — headless Chromium (pre-installed) for solo automated work: verify a change end-to-end, reproduce a bug, capture before/after screenshots, scrape the rendered DOM, watch console + network, codegen a test. shared-browser — one live browser you and the human drive together: they watch and click it in a normal host tab (over noVNC, on a per-container allocated port so parallel devcontainers never collide) while Claude attaches over loopback CDP to the same instance — for co-debugging, in-place CSS/DOM verification with measured proof (getComputedStyle, getBoundingClientRect), a real login (OAuth/captcha) completed by the human while Claude observes, and pairing on a flow. A decision tree keeps them distinct: no human watching → playwright. A status-line mod shows the noVNC URL under the prompt while the shared browser is up (⚠ when the host-side forward isn't confirmed), read from shared-browser status --json.
housecommands upgrade, add-layer, skills new, firebase-app-hosting · hook, the house band modThe house standard: create a project, or bring any repo onto it, as a stack of layers. /bespunky-house:new creates a project; by default the agent preset: the house DX (composed devcontainer with the Claude CLI & VS Code extension, Claude settings, window identity, HOUSE.md + a tailored CLAUDE.md) on the Nx floor — no package.json, no framework. Everything else is something to wear: --preset=node for a Node workspace, --preset=angular for the house web app (a clean --minimal Angular app, the dev loop, a design system from moment zero), --firebase, or any layer via --add-layer=<csv> — in the workspace shape you pick (`--layout=apps-libspackages, --linking=pathsworkspaces). /bespunky-house:upgrade brings a project up to the current house standard (newer toolkit, the migrations in between, regenerated house artifacts — it adds no layer); /bespunky-house:add-layer <layers> is an upgrade that also brings layers into being. A SessionStart hook notices when an upgrade is due, and the house band (a hooks module, so a mod) shows the same notice as one row above the prompt — the reason, a button that asks Claude to run /bespunky-house:upgrade (or to update the toolkit, or fix a failed post-create), and Dismiss. It runs the hook's own check (--json), never the upgrade; with mods disabled the hook alone remains. firebase-app-hosting is the operational truth for deploying a house app to Firebase App Hosting: the backend's Root Directory (required for Nx), GitHub rollouts vs firebase deploy from source and how to tell which is live, where apphosting*.yaml` is read from (a walk up from the Root Directory — a nearer file shadows the root one), staging's Environment name, and moving the GitHub link between accounts or orgs (Developer Connect).
product-uxskills keep-users-oriented, astonishing-to-use, redesign-means-rethink, distill-the-brief, envision-the-experience, stage-the-vision, mock-to-choose, realize-the-vision, model-intent-not-dataExperience design. keep-users-oriented — whenever you make someone wait or move them through a process, answer the three questions — expected result? where am I? next step? — and pick the right feedback (deterministic → steps/progress; nondeterministic → estimate + notify). A universal service-design principle, expressed primarily through software UI (loading/progress, async, multi-step flows, long-running jobs, notifications, optimistic UI). astonishing-to-use — the UX co-equal force the trio was missing: a design must be astonishing to use, not only to look at (effortless, understandable, no hoops, respectful of attention, a joy in the hand, built for how people really hold devices — thumbs, one-handed, distracted, bad signal). The use and the look are two forces that ping-pong until both are great — never a one-way check — with the mission setting who leads (utility → UX leads; brand/art → concept leads, then pressure-tested). Hard bar: never below great UX; keep-users-oriented is one facet. A router (friction & flow; clarity & cognitive load; embodied & contextual use; reconciling art & use; joy of use). redesign-means-rethink — the entry gate: when asked to redesign a UI, treat it as a complete creative reconception from scratch, never a reskin of the existing code; the existing implementation has zero design authority — read it only after the new design exists, to plan teardown/migration — and a redesign runs the trio below from scratch. (A targeted tweak is not a redesign.) The experiential trio — feeling → Staging → build, one altitude ladder (sensory feeling → web-native art → engineering): envision-the-experience (the feeling) — imagine the world an interface lives in before any layout, grounded in the real situation; interrogate every element (a "menu" might become a sunflower whose petals you pick), name no implementation, restraint over spectacle, produce a Vision. stage-the-vision (the web-native art — the visual architect) — the answer to "grounding stops the bad, but what makes it ART?" It invents the bold, web-native moments that turn the feeling into something you'd screenshot, staying at the art level: it speaks the web's language (parallax, cinematic scroll, a character that turns to camera, type-as-image) but says what artful thing happens and how it feels, never how to build it. Because a model isn't a native artist, it reaches art by inventing several bold concepts and choosing the striking-yet-true, stealing from specific great work and adapting its moves, composing with craft (focal point, scale, negative space, the cinematic moment), decomposing each moment to physical truth (light, shadow, material, depth, texture as they really behave — refusing the lone primitive that only symbolizes a phenomenon: warm light is never just a gradient), and sourcing genuine art — grounded so it's not generic, restrained so it's not garish (bold ≠ loud), judged by an outside eye for beauty (never self-certified). Produces the Staging (a bold concept + concrete described moments + a visual system). mock-to-choose (the verdict — the decision instrument between the art and the build) — a person cannot approve a look by reading a description of it, so this puts the concepts in front of their eyes: it builds the cheapest throwaway thing that makes each concept judgeable, mocks every option on the table (one mock asks "is this OK?" and gets a weak yes; three ask "which one?" and get a real verdict), and shows them side by side in a Compare wall — with a phone/desktop toggle and a true-size Focus view for judging and commenting up close. A mock is shell and presentation only — layout, composition, palette, type, atmosphere, dressed in plausible dummy records — with zero functionality (dead controls, no state, no routing, no data, no build step, no deps, no app integration). Heavy concepts (a scroll cinematic, a 3D scene, a living background, physically-decomposed light) are suggested, never rendered — one representative frame, a still, a flat approximation — vivid enough that the atmosphere is unmistakable, because the point is a fast verdict, not a faithful build. Every variant shares the same dummy content, so the only difference the eye sees is the design. Every review runs on a shared harness — a mini-app shipped with the skill (assets/mock-harness/: a Compare wall + a true-size Focus view + a random-port serve.sh that hot-reloads on edit) copied verbatim into the mocks folder, so Claude authors only mocks.json (the question, what's faked, the variants) and one file per concept: every mock experience is identical and only the mocks change — the user learns the review once. Because bare low fidelity reads as low quality, each mock carries an intent layer — floating notes and hover popovers that narrate the empty house the way an architect walks a site: "the sofa goes here, sideways, facing the window", "this dot is the light — it'll float and breathe; here it's a static glow, so judge where it sits and how much of the frame it owns" — so the user judges the intent, never the shortcut. And the mocks are commentable in place: the user presses c and clicks the exact spot to pin a comment right where they point, written to comments.json on disk with full DOM context (tag, text, rect, styles, ancestor path) — so Claude reads them from a file (exact words, exact element, exact point, exact variant, exact viewport). Comments run draft → submitted → handled: the user sends them to Claude (a Submit review batch, or an auto-send toggle firing each on save), Claude acts on the submitted inbox and checks each off — a handled pin vanishes from the live mock (which only ever shows the current round's open pins) and shows resolved (a green ✓ + reply) in the Focus side-list, so the user watches their notes get checked off while the mock stays uncluttered — which means an asynchronous review works as well as a co-driven one in the shared browser (noVNC, over its own allocated port). The mock iterates in internal rounds (v1 → v2 → …): every comment is version-bound to the round it was made against, Claude commits a round (snapshotting the mock's HTML) right before re-mocking, and past rounds stay viewable read-only and comparable side by side on a History timeline — a built-in, self-ignoring record of what changed and why. The side-list also manages each comment in place (inline edit, per-row send, remove with Undo, row↔pin linking). The verdict is a real gate, not a poll: "none of these" and "a hybrid of A and B" are first-class outcomes (route upstream to re-conceive), and a mock yes is provisional — it picks a direction, it does not certify the finished art (realize-the-vision still owes the outside-eye pass on the real result). Comments are copied verbatim into DECISION.md. Mocks live in a standard dated, feature-scoped package (docs/features/<YYYY-MM-DD>-<slug>/mocks/ — inside the effort's feature package, the slug shared with the git branch) that is self-ignoring and completely throwable (nothing outside may depend on it; one rm erases every trace; the user may choose to keep any of it) — while the decision is recorded durably (DECISION.md), so the conclusion outlives the evidence. Feedback travels upstream (re-conceive in stage-the-vision) and the mock is re-made cheaply — never polished into a prototype — and its code never becomes the build. realize-the-vision (the build) — the craftsman that turns a Vision and a confirmed Staging into a real interface by researching the truest means before writing any code — engineers each staged moment, surveys the field (GSAP, Motion, three.js/R3F/angular-three, Web Animations, scroll-driven CSS, View Transitions, Lottie/Rive, Canvas/SVG/WebGL, Web Audio, haptics) and its caveats, build-vs-source (figurative art is generated/licensed, never hand-coded into path-soup), requires a confirmed Staging (else invokes stage-the-vision first), never self-certifies aesthetics, fans out across subagents against the shared contract with a coherence pass, and verifies against the feeling and the Staging in the running app. Both stage-the-vision and realize-the-vision are routers over reference libraries.
design-systemskills design-system-first, design-tokens-and-themingStyling as a system. design-system-first — the discipline: before you build any feature UI, go to the design system; never hardcode a style value (every colour, space, radius, type step, elevation, border, duration and easing is a token — a CSS custom property at runtime, consumed through the DS's zero-output author-time API — the house uses SASS; components read semantic tokens only, never a raw primitive); feature components compose DS components and tokens, they never invent appearance; the second occurrence of a UI pattern is a promotion, not a copy-paste — lift it into the DS as a reusable component (nx g @bespunky/nx-tools:ds-component <name>, one secondary entry point each), migrate both sites, delete the copies; and when the DS lacks the concept, model it (add the token, the semantic alias, the scale step, the component) — never a local override, !important, a style reach-in across a component boundary (::ng-deep, :global, :deep()), a duplicated token, or a one-off variant boolean. The DS is the single source of visual truth, so a re-theme or rebrand is a change of tokens, not a thousand component files (the styling twin of redesign-means-rethink: re-token, don't re-hardcode; the styling flavour of architecture-first: never a patch — and worse there, because CSS has no compiler to catch the drift). Ships an always-on policy the scaffold bakes into every project's imported HOUSE.rules.md. design-tokens-and-theming — the techniques, a router: two layers, one truth — CSS custom properties are the runtime layer (cascading, themeable; a mode is a re-binding of tokens, live, never a swapped stylesheet) and the SASS API is the author-time layer (zero-output functions/mixins/placeholders, summoned with @use, never a global side-effect). Clusters: token taxonomy & naming (primitive → semantic → component; a value not on a scale is a design bug, not a missing token); CSS custom properties as the runtime layer; the SASS API layer & how it's summoned (the public @forward … show barrel over _-prefixed private folders named for what they are; how it resolves in-repo vs published); theming & modes (light/dark/brand, where a mode lives, persisting it without a flash, contrast that holds in every mode); component styling & encapsulation (scoped vs shadow encapsulation, tokens in / parts out, the reach-in ban, variants as data not booleans — with Angular adapters for :host / ViewEncapsulation / ::ng-deep and ng-packagr entry points); the DS library's structure & entry points (one component = one entry point, generator-first). Encodes the visual system stage-the-vision produces — it doesn't invent the look, it makes the look live in one place.

| workflow | skills branch-and-release, feature-package, delegate-and-parallelize, local-server-isolation, session-handoff, project-standing · hooks SessionStart, PreCompact · mods branch-status, /standing, merge-gate | Ways of working — the process, order, and methodology of how work moves from idea to production, independent of what is being built. branch-and-release — the house git methodology over the project's declared branch model (.bespunky/branches.json — one integration line, ordered stages, optional release and hotfix lines; presets from trunk through development → staging → main to gitflow, chosen after an investigation of how the repo actually works, never assumed): unrelated work isolated in per-feature worktrees off the integration line, small committed increments, rebase-and-re-verify at the single divergence point, every move onto a protected line human-gated. Until a model is declared, every existing long-lived branch is protected and the skill investigates and asks once per session. feature-package — a feature is a package, not a scatter of files: one effort, one slug (the same one that names the branch and worktree), one folder — docs/features/<YYYY-MM-DD>-<slug>/ — holding everything durable the effort produces that isn't code: BRIEF.md, VISION.md, STAGING.md, DECISION.md, the throwaway self-ignoring mocks/, and the effort's handoffs/ batons. Born with the worktree and filled as the work happens (a doc written at the end is a memory, and memories are where the reasons go missing); every artifact-producing skill writes into it instead of inventing a private home. Two rules: the conclusion is durable, the evidence is disposable (decisions and roads-not-taken are committed and permanent; mocks and scratch are self-ignoring, depended on by nothing, binned by default), and the user's own words are the most valuable line in the package — quote the sentence that settled it, never paraphrase. It answers "six months on, why was it done this way, and what did we already rule out?" delegate-and-parallelize — the session is an orchestrator, not a worker: decompose the goal into units, delegate every unit that isn't atomic to a subagent, and run the independent ones at once — recursively, an agent handed a still-decomposable unit splitting it again until a unit is atomic, trivial, strictly serial, or contended. One move settles two bills: context (everything read inline is permanent, and permanent cost is what forces compaction, which degrades every turn after it — a subagent reads forty files and hands back fifteen lines) and wall-clock (independent units cost the slowest, not the sum), so the default inverts to work inline only when delegating would cost more than it saves. Two halves decide whether it works in practice: the delegated-task contract (a subagent shares none of your context, so its prompt is self-contained and its return shape is specified — a distillation, never a transcript, or the context you delegated to avoid lands in your window anyway) and supervision (a parent never exits while a child it spawned is still running; it checks on long-running children, because silence reads the same whether an agent is working or wedged, and accounts for every one before it closes — no zombies left burning tokens toward a result nobody will read). And the third pillar, resumability — the tree must outlive the session that started it, because a crash, a dropped connection, a stop (deliberate or mistaken), a permissions error or a container rebuild evaporates the orchestrator's context and with it the plan, what's still outstanding, and every result already paid for. So nothing is dispatched before the plan is on disk, and each state change is written as it happens into a ledger in the effort's package (handoffs/<ts>-fanout.md): stable unit ids, per-unit status, whether each unit is safe to re-run, the returned distillations stored inline (a result that lives only in a context window dies with it), any Workflow runId verbatim — one unrecorded string is the difference between a near-free resume and a full re-run — and what was not covered. Every agent at every depth leaves traces, so a fresh session resumes the outstanding work instead of redoing the expensive work that already succeeded. Plus write-contention isolation, what is never delegated (the decision, the user's intent, the final synthesis, the human-gated promotions), and adversarial verification of what comes back. An agent budget — the total agents the tree may create (default 12, set per request or per project), estimated before anything is dispatched — and you're asked to confirm when the estimate needs more — then conserved and carved down the tree as each child's share — caps spend against your usage limits without capping depth: fewer, fatter units with share enough to recurse, batched leaves, the share worded as an allowance never a ban, and inline fallback when it runs out. Subagents are the everyday tier within that budget; Workflows need the user's explicit opt-in, since a skill that auto-fired cannot authorize its own spending. local-server-isolation — bind a random free port, never the default/forwarded one the user's own server owns. session-handoff — carry a live effort across a context boundary into a fresh session: capture writes a distilled relay baton (into the effort's package), resume re-grounds against reality; the user's corrections are captured first-class, and durable ones promoted to persistent memory. project-standing — the cold pick-up: orient in a project you've been away from, derived from git + the feature packages (never a hand-maintained status doc — that's the first thing to rot) — which efforts are live, stalled, or concluded, which baton to read first, live efforts in full and concluded ones collapsed to a one-line conclusion so orienting costs the same at effort #300 as at #3; scopes for on-demand history search and additive archive-sweep. Two hooks make continuity reliable rather than hoped-for: a SessionStart hook that stays silent unless in-flight work has gone dormant, then relays a fact (detect-don't-execute), and a PreCompact hook that writes a mechanical checkpoint into the live effort's package before context is lost — even headless — then asks the model to distill it. And a mod (hooks/branch-status.ts) pins where this checkout sits in the declared model to the status line — feat/x → development → main, a warning on a protected line, a violations count from verify, or an honest undeclared / unreadable — read from the engine (branches.mjs status --json), never re-derived; it displays and never acts. Another mod (hooks/standing.tsx): /standing opens a pane (never unasked) that leads with the answer (Nothing in flight, or the live / dormant packages across every worktree, each with its newest baton and a Resume button) and collapses finished work to one line (N concluded · latest: …, expandable to the five most recent); Resume only queues a prompt for Claude. All of it is drawn from the same derivation engine (project-standing/scripts/standing.mjs) the SessionStart notice reads, so the two can never disagree. And the merge gate (hooks/merge-gate.tsx): when Claude is done and proposes to land its branch, it calls the plugin's propose_move tool instead of asking "shall I merge?" in prose, and a band above the prompt offers Land on <integration>, Land & promote to <next stage> (only where the engine plans that promotion), Push branch and Not yet — every name and op

Source 3 files
hooks/band.tsx 391 lines
1// bespunky-voice — the VOICE BAND: one row above the prompt that says what is
2// being said (🔊) or heard (🎙) right now, with Replay (r) and Stop (s).
3//
4// WHY IT IS A VIEW OVER STATE FILES. The audio does not live in the engine: an
5// utterance is a detached process group started by speaker.sh, a recording is
6// listen.sh's parecord, and either may be started by a command hook, the
7// /speak command or the ask_by_voice MCP server — three separate processes,
8// none of them this module. The runtime therefore publishes the truth as files
9// under ~/.claude/bespunky-voice/ (`.speaking.pid` + `last-utterance.txt`,
10// `.listening.pid` + `.hearing`), and the band only READS them: a poller folds
11// them into one `$.state` value (types/index.d.ts) and the drawing reads that,
12// so it redraws exactly when what it shows changes. Its two buttons and its
13// Esc hook act through the runtime's own front door (`voice.sh stop|replay`),
14// never by touching a process themselves.
15//
16// THE PLUGIN WORKS WITHOUT IT. Speaking, listening, /speak stop and replay are
17// all command hooks, a command and an MCP tool; with mods disabled (or on a
18// build without them) those remain the floor and nothing here is missed but
19// the view. Never put behaviour here that the floor needs.
20//
21// VOICE HEALTH. When nothing is said or heard, the band warns about anything
22// that will let the person down — no audio connection to this computer, a
23// broken Piper about to speak in the robotic voice, no speech recognition for
24// ask_by_voice — so neither silence nor the robotic voice is ever a surprise. The verdict is the runtime's (`voice-health.sh`, the same probe
25// /speak status runs); the band only reads it, once at the start and again when
26// an engine's install moves or the verdict ages — never on the 300ms tick.
27
28import type { EngineInterface, FsEntry, Register } from 'claude-code'
29
30import type { VoiceBand, VoiceHealth } from '../types/index.d.ts'
31
32import { BAND_GUTTER_CELLS, BrandFrame, brandLine } from './_brand.tsx'
33
34/** The one value the band draws from; written by the poller alone. */
35const BAND = { plugin: 'bespunky-voice', key: 'band' } as const
36const IDLE: VoiceBand = { phase: 'idle' }
37
38/**
39 * The ask_by_voice tool, however the host spells the plugin's server
40 * (`mcp__plugin_bespunky-voice_bespunky-voice__ask_by_voice` when installed).
41 * Module-private on purpose: `claude plugin validate` reads a matcher only
42 * from a const nothing else references.
43 */
44const ASK_BY_VOICE = /^mcp__(?:plugin_[^_]+_)?bespunky-voice__ask_by_voice$/
45
46const POLL_MS = 300
47/** A state file older than this is a crashed writer's leftover, not speech. */
48const STALE_SPEAKING_MS = 180_000
49const STALE_LISTENING_MS = 60_000
50/** How long the last utterance stays up after speech ends. */
51const LINGER_MS = 20_000
52/** A health verdict is re-taken this often even when nothing on disk moved. */
53const HEALTH_TTL_MS = 600_000
54/** The runtime entries whose change means the verdict may have changed. */
55const HEALTH_INPUTS = ['voice-health.sh', 'audio-endpoint.sh', 'tts-engine.sh', 'stt-engine.sh', 'piper', 'voices', 'whisper']
56
57type Verb = 'stop' | 'replay'
58
59/** What the runtime's files say, one poll's worth. */
60export type Snapshot = {
61  /** `.speaking.pid`'s mtime, when it exists. */
62  speakingSince?: number
63  /** `.listening.pid`'s mtime, when it exists. */
64  listeningSince?: number
65  /** `last-utterance.txt`. */
66  lastText: string
67  /** `.hearing`. */
68  heard: string
69  /** The engines' last verdict, when one has been taken. */
70  health?: VoiceHealth
71  /** The warning the person dismissed this session. */
72  dismissed?: string
73}
74
75/**
76 * The band's next phase from the previous one, the files, and the time: the
77 * whole policy, pure, so the poller is only plumbing.
78 */
79export function decide(previous: VoiceBand, files: Snapshot, now: number): VoiceBand {
80  const isFresh = (since: number | undefined, bound: number) => since !== undefined && now - since <= bound
81
82  if (isFresh(files.speakingSince, STALE_SPEAKING_MS)) {
83    return { phase: 'speaking', text: oneLine(files.lastText) }
84  }
85  if (isFresh(files.listeningSince, STALE_LISTENING_MS)) {
86    return { phase: 'listening', heard: oneLine(files.heard) }
87  }
88  if (previous.phase === 'speaking') {
89    return { phase: 'lingering', text: previous.text, until: now + LINGER_MS }
90  }
91  if (previous.phase === 'lingering' && now < previous.until) {
92    return previous
93  }
94  const warning = files.health && healthWarning(files.health)
95  if (warning && warning !== files.dismissed) {
96    return { phase: 'warning', text: warning }
97  }
98
99  return IDLE
100}
101
102/** What a healthy-enough voice says: nothing. Otherwise one short line. */
103export function healthWarning(health: VoiceHealth): string | undefined {
104  const audio = {
105    ok: undefined,
106    native: undefined,
107    unreachable: 'no audio connection',
108  }[health.audio]
109  const tts = {
110    natural: undefined,
111    system: undefined,
112    broken: 'Piper broken — robotic fallback',
113    robotic: 'robotic voice (no Piper)',
114    none: 'no speech engine',
115  }[health.tts]
116  const stt = {
117    ok: undefined,
118    broken: 'speech recognition broken',
119    missing: 'no speech recognition',
120  }[health.stt]
121  const problems = [audio, tts, stt].filter(Boolean)
122
123  return problems.length === 0 ? undefined : `voice: ${problems.join('; ')} · /speak status`
124}
125
126/** `voice-health.sh`'s three lines, or undefined when they aren't its output. */
127export function parseHealth(stdout: string): VoiceHealth | undefined {
128  const verdicts = new Map(stdout.split('\n').map(line => line.split('\t', 2) as [string, string?]))
129  const audio = verdicts.get('audio')
130  const tts = verdicts.get('tts')
131  const stt = verdicts.get('stt')
132  const isAudio = (v?: string): v is VoiceHealth['audio'] => ['ok', 'native', 'unreachable'].includes(v ?? '')
133  const isTts = (v?: string): v is VoiceHealth['tts'] => ['natural', 'broken', 'robotic', 'system', 'none'].includes(v ?? '')
134  const isStt = (v?: string): v is VoiceHealth['stt'] => ['ok', 'broken', 'missing'].includes(v ?? '')
135
136  return isAudio(audio) && isTts(tts) && isStt(stt) ? { audio, tts, stt } : undefined
137}
138
139/**
140 * Runs `onAbort` if `signal` aborts while `work` is in flight, and never
141 * after: how the Esc hook reaches the voice without touching the result.
142 */
143export async function guardAbort<T>(signal: AbortSignal, work: () => Promise<T>, onAbort: () => void): Promise<T> {
144  if (signal.aborted) {
145    onAbort()
146  }
147  signal.addEventListener('abort', onAbort, { once: true })
148  try {
149    return await work()
150  } finally {
151    signal.removeEventListener('abort', onAbort)
152  }
153}
154
155export const register: Register = on => {
156  let poller: { cancel: () => void } | undefined
157  /** The warning dismissed this session: it stays down until it changes. */
158  const dismissal = { text: '' }
159
160  on('session.start', async ($, e, next) => {
161    poller?.cancel()
162    dismissal.text = ''
163    poller = await startPolling($, dismissal)
164
165    return next(e)
166  })
167
168  // Esc reaches the voice: an interrupted ask_by_voice silences the question
169  // and ends the recording. The tool's own result passes through untouched.
170  on('tool.call', { tool: ASK_BY_VOICE }, ($, e, next) =>
171    guardAbort(next.signal, () => next(e), () => void runVoice($, 'stop')),
172  )
173
174  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
175    const { value: shown = IDLE } = await $.state.get(BAND)
176
177    if (e.props.hasSurvey || shown.phase === 'idle') {
178      return next(e)
179    }
180
181    const ui = $.ui.resolve(e)
182    const { Box, Button, Text } = ui
183    // The toolkit's frame (./_brand.tsx) leads the band with its wordmark: that gutter is not the line's.
184    const columns = (e.props.bodyColumns || e.viewport?.columns || 80) - BAND_GUTTER_CELLS
185
186    if (shown.phase === 'warning') {
187      const room = columns - DISMISS_CELLS
188
189      return (
190        <BrandFrame ui={ui} site={e}>
191        <Box flexDirection="row" gap={1}>
192          <Box flexGrow={1} flexShrink={1}>
193            <Text color="warning" wrap="truncate-end">
194              {`⚠ ${fit(shown.text, room - 2)}`}
195            </Text>
196          </Box>
197          <Button
198            key="voice-dismiss"
199            label="Dismiss"
200            hotkey="d"
201            role="dismiss"
202            onPress={() => {
203              dismissal.text = shown.text
204              void $.state.set(BAND, IDLE)
205            }}
206          />
207        </Box>
208        </BrandFrame>
209      )
210    }
211
212    const isLingering = shown.phase === 'lingering'
213    const room = columns - (isLingering ? REPLAY_CELLS : BOTH_CELLS)
214    const line =
215      shown.phase === 'listening'
216        ? shown.heard === ''
217          ? '🎙 listening…'
218          : `🎙 “${fit(shown.heard, room - 5)}”`
219        : `🔊 ${fit(shown.text, room - 3)}`
220
221    return (
222      <BrandFrame ui={ui} site={e}>
223      <Box flexDirection="row" gap={1}>
224        <Box flexGrow={1} flexShrink={1}>
225          <Text dimColor={isLingering} wrap="truncate-end">
226            {line}
227          </Text>
228        </Box>
229        <Button key="voice-replay" label="Replay" hotkey="r" onPress={() => void runVoice($, 'replay')} />
230        {!isLingering && (
231          <Button key="voice-stop" label="Stop" hotkey="s" variant="primary" onPress={() => void runVoice($, 'stop')} />
232        )}
233      </Box>
234      </BrandFrame>
235    )
236  })
237}
238
239/** `[ Replay ]` and `[ Stop ]` with their gaps, in cells. */
240const REPLAY_CELLS = 11
241const BOTH_CELLS = 20
242const DISMISS_CELLS = 12
243
244/** Polls the runtime's files into `band`, writing only on a change. */
245async function startPolling($: EngineInterface, dismissal: { text: string }) {
246  const dir = await voiceDir($)
247
248  if (dir === undefined) {
249    $.ui.log('bespunky-voice: HOME is unset; the voice band stays down', { to: 'debug' })
250
251    return undefined
252  }
253
254  const lastText = cachedText($, `${dir}/last-utterance.txt`)
255  const heard = cachedText($, `${dir}/.hearing`)
256  const health = healthProbe($, dir)
257  let isPolling = false
258
259  /** One `fs.list` per tick; a file's text is read only when it changed. */
260  const snapshot = async (now: number): Promise<Snapshot> => {
261    const entries: FsEntry[] = await $.fs.list(dir).catch(() => [])
262    const of = (name: string) => entries.find(entry => entry.name === name)
263    const speaking = of('.speaking.pid')
264    const listening = of('.listening.pid')
265
266    return {
267      speakingSince: speaking?.mtimeMs,
268      listeningSince: listening?.mtimeMs,
269      lastText: speaking ? await lastText(of('last-utterance.txt')) : '',
270      heard: listening ? await heard(of('.hearing')) : '',
271      health: health(entries, now),
272      dismissed: dismissal.text,
273    }
274  }
275
276  return $.clock.every(POLL_MS, () => {
277    if (isPolling) {
278      return
279    }
280    isPolling = true
281    void (async () => {
282      try {
283        const now = await $.clock.now()
284        const [files, { value: previous = IDLE, version }] = await Promise.all([snapshot(now), $.state.get(BAND)])
285        const next = decide(previous, files, now)
286
287        if (JSON.stringify(next) !== JSON.stringify(previous)) {
288          await $.state.set(BAND, next, { ifVersion: version })
289        }
290      } finally {
291        isPolling = false
292      }
293    })()
294  })
295}
296
297type TextFile = (entry: FsEntry | undefined) => Promise<string>
298
299/**
300 * The engines' last verdict from `voice-health.sh`, re-taken in the background
301 * when one of HEALTH_INPUTS moved or the verdict is HEALTH_TTL_MS old — the
302 * probe runs piper, so it never rides the tick. No runtime script, no verdict:
303 * a machine without the voice installed sees nothing.
304 */
305function healthProbe($: EngineInterface, dir: string) {
306  let verdict: VoiceHealth | undefined
307  let takenFor = ''
308  let takenAt = -Infinity
309  let isProbing = false
310
311  return (entries: FsEntry[], now: number) => {
312    const inputs = HEALTH_INPUTS.map(name => {
313      const entry = entries.find(each => each.name === name)
314
315      return `${name}:${entry?.mtimeMs ?? '-'}`
316    }).join(' ')
317    const hasScript = entries.some(entry => entry.name === 'voice-health.sh')
318
319    if (!hasScript) {
320      verdict = undefined
321    } else if (!isProbing && (inputs !== takenFor || now - takenAt >= HEALTH_TTL_MS)) {
322      isProbing = true
323      takenFor = inputs
324      takenAt = now
325      void $.process
326        .run(['bash', `${dir}/voice-health.sh`], { timeoutMs: 20_000 })
327        .then(ran => (verdict = ran.exitCode === 0 ? parseHealth(ran.stdout) : undefined))
328        .catch(() => (verdict = undefined))
329        .finally(() => (isProbing = false))
330    }
331
332    return verdict
333  }
334}
335
336/** A file's text, re-read only when its mtime or size moved. */
337function cachedText($: EngineInterface, path: string): TextFile {
338  let stamp = ''
339  let text = ''
340
341  return async entry => {
342    if (entry === undefined) {
343      return ''
344    }
345    const now = `${entry.mtimeMs}:${entry.size}`
346    if (now !== stamp) {
347      text = await $.fs.read(path).catch(() => '')
348      stamp = now
349    }
350
351    return text
352  }
353}
354
355/** Runs `voice.sh <verb>` fire-and-forget; a failure is a toast naming it. */
356async function runVoice($: EngineInterface, verb: Verb) {
357  const dir = await voiceDir($)
358  const failed = (why: string) => $.ui.toast(brandLine(`Voice ${verb} failed: ${why}`))
359
360  if (dir === undefined) {
361    return failed('HOME is unset')
362  }
363  try {
364    const ran = await $.process.run(['bash', `${dir}/voice.sh`, verb], { timeoutMs: 10_000 })
365    if (ran.exitCode !== 0) {
366      failed(ran.stderr.trim().split('\n').pop() || `exit ${ran.exitCode}`)
367    }
368  } catch (error) {
369    failed(error instanceof Error ? error.message : String(error))
370  }
371}
372
373/** ~/.claude/bespunky-voice, where the runtime publishes itself and its state. */
374async function voiceDir($: EngineInterface) {
375  const home = await $.env.get('HOME')
376
377  return home ? `${home.replace(/\/+$/, '')}/.claude/bespunky-voice` : undefined
378}
379
380function oneLine(text: string) {
381  return text.replace(/\s+/g, ' ').trim()
382}
383
384/** Cuts `text` to `cells` (at least a few), marking the cut. */
385function fit(text: string, cells: number) {
386  const room = Math.max(8, cells)
387  const chars = [...text]
388
389  return chars.length <= room ? text : `${chars.slice(0, room - 1).join('')}…`
390}
391
types/index.d.ts 50 lines
1// bespunky-voice — the voice band's state contract.
2//
3// The band (hooks/band.tsx) is a VIEW over the voice runtime's state files; its
4// poller folds them into ONE session value, and the drawing reads only that, so
5// a redraw happens exactly when what the band shows changes. Declared here, in
6// the plugin's contract, because `$.state` values are typed by the owner's
7// `PluginState` entry and anyone may read them (another plugin wanting to know
8// "is Claude speaking right now?" reads this, never the files).
9
10/**
11 * What the voice band shows, one phase at a time.
12 *
13 * - `idle`      nothing is said or heard: the band draws nothing of its own.
14 * - `speaking`  an utterance is playing; `text` is what is being said.
15 * - `listening` a recording is open; `heard` is the transcript so far ('' before
16 *               any words).
17 * - `lingering` speech just ended; `text` stays up (dimmed, Replay only) until
18 *               `until` (ms since the epoch), so the person can still catch it.
19 * - `warning`   nothing is said or heard, and an engine will let the person
20 *               down (VoiceHealth); `text` says which, until it is dismissed.
21 */
22export type VoiceBand =
23  | { phase: 'idle' }
24  | { phase: 'speaking'; text: string }
25  | { phase: 'listening'; heard: string }
26  | { phase: 'lingering'; text: string; until: number }
27  | { phase: 'warning'; text: string }
28
29/**
30 * The voice engines' health, as the runtime's `voice-health.sh` reports it —
31 * the band never judges an engine itself.
32 *
33 * - `tts`: which engine will actually speak — `natural` (Piper works), `broken`
34 *   (Piper is installed but fails, so speech falls back to the robotic voice),
35 *   `robotic` (no Piper; espeak-ng), `system` (macOS say), `none`.
36 * - `stt`: whether ask_by_voice can hear — `ok`, `broken` (whisper-cli is there
37 *   but cannot run), `missing`.
38 */
39export type VoiceHealth = {
40  audio: 'ok' | 'native' | 'unreachable'
41  tts: 'natural' | 'broken' | 'robotic' | 'system' | 'none'
42  stt: 'ok' | 'broken' | 'missing'
43}
44
45declare module 'claude-code' {
46  interface PluginState {
47    'bespunky-voice': { band: VoiceBand }
48  }
49}
50
hooks/_brand.tsx 148 lines
1// GENERATED from tools/mod-brand/brand.tsx by `node tools/mod-brand/project.mjs --write` — DO NOT EDIT.
2// Change the brand at its source; CI fails when this copy drifts from it.
3
4// ✦ bespunky — THE TOOLKIT'S MOD BRAND: one mark, one accent, one frame, so every toolkit mod (a pane, a band
5// above the prompt, a status entry, a toast) reads as one family — and never as Claude's own UI or another
6// plugin's.
7//
8// THE SINGLE SOURCE. Plugins cannot import each other's files, so this file is PROJECTED, verbatim under a
9// generated header, into `hooks/_brand.tsx` of every plugin whose hooks.json names a module
10// (`node tools/mod-brand/project.mjs --write`); CI fails on any drift. Change the brand HERE, never in a
11// projection, and never re-type the glyph or the colour in a mod.
12//
13// THE RULES IT ENCODES.
14//   - Plain-text surfaces (status line, toast) carry the GLYPH alone: Claude Code titles both with the plugin's
15//     name (`bespunky-workflow: …`), which already says bespunky — spelling it again reads
16//     `bespunky-workflow: ✦ bespunky · …`. `brandLine(text)`.
17//   - A mod's slash command answers in the transcript under the PLUGIN's name (`bespunky-workflow: …`), which
18//     reads as any plugin's. Its output row is redrawn as the toolkit's: `✦ bespunky · <command>` then the
19//     text. `<BrandCommandRow>`, from a `ui.render` hook on `{ component: 'CommandOutput', props: { command } }`.
20//   - Anything drawn BELOW the transcript (every band; a pane seated inline) starts one blank row down, so it
21//     never reads as the tail of Claude's reply.
22//   - The accent is a raw colour, not a theme key: theme keys are Claude's palette, and the point is to look
23//     like something that is not Claude. A mid-tone violet keeps its contrast on light and dark themes; with no
24//     colour at all the glyph and the bold wordmark still carry the mark.
25//
26// Pure: no `$`, no state, no I/O — the mods pass in their surface's element table and render argument.
27
28import type { BoxProps, ElementConstructor, RenderChildren, RenderSurface, TextProps } from 'claude-code'
29
30export const BRAND = {
31  glyph: '✦',
32  name: 'bespunky',
33  /** Violet-500: distinct from Claude's clay, readable on light and dark. */
34  accent: '#8b5cf6',
35} as const
36
37/** The wordmark: `✦ bespunky`. */
38export const WORDMARK = `${BRAND.glyph} ${BRAND.name}`
39
40/** A status entry or toast, marked as the toolkit's: `✦ <text>` (the engine titles both with the plugin's name). */
41export function brandLine(text: string) {
42  return `${BRAND.glyph} ${text}`
43}
44
45/** A drawn site's title: `✦ bespunky · <mod>`, or the wordmark alone. */
46export function brandTitle(mod?: string) {
47  return mod === undefined ? WORDMARK : `${WORDMARK} · ${mod}`
48}
49
50/** Cells a band's brand gutter takes from its row: the wordmark and the gap after it. */
51export const BAND_GUTTER_CELLS = WORDMARK.length + 1
52
53/** The two elements every surface's table carries, which is all the frame draws with. */
54export type BrandUi = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
55
56/** The parts of a `ui.render` argument the frame reads: where it draws, and how wide. */
57export type BrandSite =
58  | { surface: RenderSurface; component: 'AbovePrompt'; props: { bodyColumns: number } }
59  | { surface: RenderSurface; component: 'Pane'; props: { bodyColumns: number; placement: 'dock' | 'inline' } }
60
61export type BrandFrameProps = {
62  /** `$.ui.resolve(e)`, the surface's own elements. */
63  ui: BrandUi
64  /** The render argument `e` itself. */
65  site: BrandSite
66  /** The mod's short name for a pane's title (`standing`); a band shows the wordmark alone. */
67  mod?: string
68  children?: RenderChildren
69}
70
71/**
72 * The toolkit's frame for a drawn site. A band: one blank row down from the transcript, the wordmark in a
73 * gutter, the mod's rows beside it. A pane: the branded title over a dim rule (the rule on the terminal only —
74 * the other surfaces frame their panes natively, and a rule of box-drawing glyphs is a terminal idiom), then
75 * the body; one blank row down when seated inline under the transcript.
76 */
77export function BrandFrame({ ui, site, mod, children }: BrandFrameProps) {
78  const { Box, Text } = ui
79  const columns = site.props.bodyColumns
80
81  if (site.component === 'AbovePrompt') {
82    return (
83      <Box flexDirection="row" gap={1} marginTop={1}>
84        <Text color={BRAND.accent} bold>
85          {WORDMARK}
86        </Text>
87        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
88          {children}
89        </Box>
90      </Box>
91    )
92  }
93
94  const title = brandTitle(mod)
95  const rule = site.surface === 'terminal' ? Math.max(0, columns - title.length - 1) : 0
96
97  return (
98    <Box flexDirection="column" gap={1} marginTop={site.props.placement === 'inline' ? 1 : 0}>
99      <Box key="brand-title" flexDirection="row" gap={1}>
100        <Text color={BRAND.accent} bold>
101          {title}
102        </Text>
103        {rule > 0 && <Text dimColor>{'─'.repeat(rule)}</Text>}
104      </Box>
105      {children}
106    </Box>
107  )
108}
109
110/**
111 * A thin dim rule between items of a list, sized to the site (`columns` cells). On the terminal a line of
112 * `─`; elsewhere a one-row gap does the same job without a glyph the surface's font may not tile.
113 */
114export function BrandDivider({ ui, site, columns }: { ui: BrandUi; site: BrandSite; columns?: number }) {
115  const { Box, Text } = ui
116  const width = Math.max(0, columns ?? site.props.bodyColumns)
117
118  return site.surface === 'terminal' ? (
119    <Text dimColor wrap="truncate-end">
120      {'─'.repeat(width)}
121    </Text>
122  ) : (
123    <Box height={1} />
124  )
125}
126
127/**
128 * A toolkit command's output row in the transcript: `✦ bespunky · <command>` in the accent, then the text the
129 * command answered. `text` is the row's own (`e.props.text`); `plugin` is the answering plugin's manifest name
130 * (`$.plugin.name`), whose `name: ` lead — the engine's attribution of a hook-answered row — the brand replaces.
131 */
132export function BrandCommandRow({ ui, command, text, plugin }: { ui: BrandUi; command: string; text: string; plugin: string }) {
133  const { Box, Text } = ui
134  const lead = `${plugin}: `
135  const body = text.startsWith(lead) ? text.slice(lead.length) : text
136
137  return (
138    <Box flexDirection="row" gap={1}>
139      <Text color={BRAND.accent} bold>
140        {brandTitle(command)}
141      </Text>
142      <Box flexGrow={1} flexShrink={1}>
143        <Text dimColor>{body}</Text>
144      </Box>
145    </Box>
146  )
147}
148