SLOPSHOPPER

undertone

Plays your Claude Code sessions as music: each session is a voice, commits resolve the chord, a broken build holds a sour note

guardcommandpromptprocessnetworktimer
v0.14.0no licenseupdated 2026-10-08coterboat/undertone
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · undertone
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /undertone ⎿ undertone: undertone: the bridge did not start. Run `npm start` in /plugins/undertone to see why. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

undertone

Your Claude Code sessions, played as music, and a way for them to tell you what needs you without you looking. Each session is a voice with its own three-note call, every tool call is a note, a commit resolves the chord, a broken build sounds a sour note that names what broke, and every question waiting on you hums underneath until you answer.

Claude Code is the only coding tool it hears. Outside sources (a production site, a bot) can post events to it; Codex or Grok sessions would need sources of their own.

What's built, what's next and what waits on purpose: docs/roadmap.md.

Quick start

npm start

Then open http://127.0.0.1:4747 and press Play. With the plugin installed you don't need the terminal: the first Claude Code session starts the bridge in the background and it leaves after a quiet hour, so there's no window to keep open. /undertone opens the page (and starts the bridge if it isn't running).

What needs you, by ear

| You hear | It means | | :- | :- | | A session's call ending on a rising, held note | That session needs you. A permission prompt asks again every minute (three times at most); an idle session asks once | | A session's call settling on the chord's root | That session finished its turn: your move | | A soft bell figure that rises and stays open, every so often | Questions waiting on you, counted from coter-kit's waiting notes in every project you have on. More questions, more often and longer (about every 40 s from ten on); a new one asks at once, an answer settles. It used to be a drone that grew with the count, which never stopped and sounded like a lawnmower | | A session's call ending flat, then a sour note held for 8 bars and restated briefly every 16 while it stays broken | Something that session runs is broken. Each kind has its own sour note: tests a tritone, typecheck a high grinding second, lint a softer seventh, build a low second, production errors a low rumble, a red GitHub build a high tritone; the score names it ("tests (vitest) failing") |

A call comes from the project and worktree, not the session, so jobsnearme · oct2-signup sounds the same every time it starts and you learn who's calling. The ending carries the meaning: settled means done, rising means it needs you. Everything else is texture: tool calls are notes in the session's voice (reads low, edits on chord tones, commands high), drums fade in while Claude works, a commit or push resolves the progression home with bells in that session's lane, a checklist step climbs a bell, and a fixed check plays the session's call settled and slides the sour note into the chord. Each check fails and clears on its own: a passing lint doesn't clear failing tests. A failure is held for 8 bars, then only restated (no constant tones: a red deploy used to hold one for hours); it's held again when a different check breaks. Muting a lane, or switching its project off, takes its failure out of your ears at once (the score still shows it).

A scheduled run (a desktop scheduled task) plays quietly: no calls when its turn ends, no notes for just looking around, only edits, commits, failures and a permission prompt that blocks it.

The score on the page shows the same: a row per session, a diamond in the row that committed or pushed, a red wave named after the check while it fails, a ring when a session needs you, and the questions waiting on you along the bottom (a small rise each time they're asked). Point at anything on it to see what it is, who made it and when.

Sound engines

Pick one at the top of the page. The page conducts in every case (harmony, timing, who plays what), so you can switch mid-song and compare.

| Engine | What it is | Runs | Control for Claude | | :- | :- | :- | :- | | Synth | Tone.js, everything synthesized: FM keys, saw pads, membrane drums | Anywhere, also the published page | Full, but every sound is hand-built in the browser | | Sampled | Real recordings via smplr: Wurlitzer, vibraphone, nylon guitar, Mellotron strings, upright bass, LinnDrum | Local page (fetches samples) | Pick and layer instruments; little sound design | | Strudel | Strudel's sound engine (superdough): Salamander piano, folk harp, vibes, hand chimes, an MPC60 kit, effects on every note | Local page (fetches sample banks) | Per-note effects and a big library | | SuperCollider | Native synthesis: Karplus-Strong plucks, modal bells, FM Rhodes, analog-style drums, through your computer's audio | Local page, with SuperCollider installed | The most: synths and effects written as code in sc/undertone.scd |

SuperCollider is started by the bridge (sclang running sc/undertone.scd) the first time you pick it. It boots on your default output device, using an output-only device for input as well, so it never asks macOS for the microphone. If you switch outputs (say, to headphones), stop the bridge and start it again so SuperCollider picks up the new device. It plays outside the page, so once no page has listened for 10 seconds (a tab closed, the browser quit) the bridge silences it; the next page picks it up again. Install it with brew install --cask supercollider on a Mac; on Windows the bridge finds the newest C:\Program Files\SuperCollider-*\sclang.exe. UNDERTONE_SCLANG points it anywhere else.

Ears

undertone can't be heard by Claude directly, so it keeps what you hear in a form Claude can read. On the local page, next to the engine switch:

| Button | Keeps | | :- | :- | | Love this | The last 20 seconds | | This grates | The last 20 seconds | | Snapshot | The last 30 seconds |

Each one lands in ~/.undertone/ear/<date>/<time>-<engine>-<kind>/:

| File | What it is | | :- | :- | | clip.wav | The audio: the page records it on the audio thread, so a busy page can't drop any (SuperCollider keeps a rolling 30 seconds of its own output and hands it over) | | spectrogram.png | 30 Hz at the bottom to 16 kHz at the top; faint lines at 100 Hz, 1 kHz, 10 kHz and every 5 s; a loudness strip underneath with red ticks at clicks | | stats.json | RMS and peak, clipping, clicks per minute, energy per band (sub, bass, low-mid, mid, presence, air), brightness | | state.json | What was playing: engine, mood, key, chord, tempo, texture, every lane | | events.json | The session events around the clip | | critique.md | What Gemini heard, when a key is set (below) |

~/.undertone/ear/index.jsonl lists every clip, so reactions can be compared over time. The analysis has no dependencies (ear/analyze.mjs).

The buttons don't change the music by themselves. A weekly listening review does: it reads what you saved (node ear/review.mjs --since <date> for the numbers, then the grated clips' spectrograms and Gemini's critiques), traces each pattern to the code and proposes tuning for you to pick. How, and the reviews so far: docs/listening/. Grates teach it more than loves: press This grates for small things too.

Gemini as a second pair of ears

With a Gemini API key, every clip is sent to Gemini, which writes critique.md: the vibe, each element, problems, and the three changes that would help most. It listens blind (no measurements), so it's an independent second opinion. Only the audio and musical facts go to Google, never project names; a reaction's note only with UNDERTONE_GEMINI_NOTE=1. Get a key at Google AI Studio, copy it, and save it without pasting it anywhere else:

mkdir -p ~/.config/undertone && pbpaste > ~/.config/undertone/gemini.key && chmod 600 ~/.config/undertone/gemini.key

GEMINI_API_KEY works too. UNDERTONE_GEMINI_MODEL picks the model (default gemini-flash-latest). To critique an older clip: node ear/gemini.mjs <clip folder>.

Your phone

When a session has had a permission prompt waiting for a minute (or has sat idle for five), the bridge pings your phone through ntfy: a free app with no account. Anything the session does first cancels the ping, so at your desk you only hear the call. Setup is ~/.config/undertone/ntfy.json (chmod 600):

{ "server": "https://ntfy.sh", "topic": "undertone-<random>", "names": "project", "permissionAfterSec": 60, "idleAfterSec": 300 }

| Field | Default | | | :- | :- | :- | | server | https://ntfy.sh | Must be https (plain http only on localhost) | | topic | | At least 16 characters unless you set a token | | token | | An ntfy access token, sent as Authorization: Bearer; or UNDERTONE_NTFY_TOKEN | | names | project | What the ping says: full (project · worktree), project, or none ("A session needs your OK") | | enabled | true | false turns pings off without deleting the file (say, when another app owns phone pushes) | | permissionAfterSec, idleAfterSec | 60, 300 | How long a session waits before your phone hears about it |

What leaves the machine: a title (that name plus "needs your OK" or "is waiting for you"), a fixed message, a priority and a tag, sent to server as JSON. Never code, prompts or anything else from the session. On the public ntfy.sh, the topic is the only protection without a token: anyone who knows it can read your pings and send fake ones, and ntfy.sh keeps messages for about 12 hours. A reserved topic with a token, or your own ntfy server, closes that.

Only Claude Code sessions the bridge has heard start (or heartbeat) can ping, never outside sources; a scheduled run pings only for a permission prompt that blocks it. At most one ping per session every 15 minutes and 12 an hour; the rest are dropped and counted. The bridge refuses to send, and says why in /health under notify, when server isn't https, the topic is short with no token, or the file can be read by other users. Install ntfy on the phone, subscribe to the topic, and curl -X POST http://127.0.0.1:4747/notify/test sends a test.

To open the page itself on the phone, put the Mac and the phone on one Tailscale network, run tailscale serve --bg 4747 on the Mac, and name the address it prints in ~/.config/undertone/hosts.json: { "hosts": ["your-mac.your-tailnet.ts.net"] }. The bridge then answers that name too (read fresh, no restart), and the page it serves there is live, not the demo. Only your tailnet's devices reach that address. Synth and Sampled play on the phone; SuperCollider plays on the Mac.

Sources beyond Claude

Anything can post events to the bridge. An outside source marks its events with origin (and its lane id starts with src-), which gives it a lane of its own that colors the music but never leads it, never calls you and never pings your phone: a pulse event (count things happened) plays that many soft notes (high chimes, or another register with kind), and fail / pass with a check hold and release the sour note.

sources/azure-metrics.mjs turns Azure Monitor metrics into a source: counts per minute (requests, model calls, errors), never logs or traces, so nothing a user typed can come through. Production errors fail as check prod-errors:<metric>. Describe sources in ~/.config/undertone/sources.json (format at the top of the file) and log in with az login; the bridge runs them itself while that file describes any, restarts them when you change it, and stops them with it, so npm start is the only window. /health says under sources whether they're running and their last line of output. Give each metric a kind to tell them apart by ear (ourPlace plays chats as high chimes and site requests as low read notes); the lane's chip counts each one. npm run sources runs them by hand instead (only one copy runs at a time), and npm run sources:mock plays made-up chatbot traffic, to hear it before anything is wired. On Windows az is az.cmd, which runs through cmd.exe, so the source refuses any argument that isn't plain (letters, digits, / _ . : + , = -). Azure publishes each minute's numbers late, so the lane trails real time by about three minutes.

sources/github-actions.mjs plays a repository's GitHub Actions: builds starting are soft notes, a green finish a chime, and each workflow's newest finished run on the watched branch decides its check, ci:<workflow> ("CI (Deploy to GitHub Pages)"), red until a green run clears it. Cancelled and skipped runs decide nothing. Only workflow names, outcomes and counts reach the bridge, never commit messages, branch names or who pushed. It asks the GitHub CLI (gh, logged in) once a minute. An entry with github in the same sources.json: { "jobsnearme-ci": { "github": "coterboat/jobsnearme", "project": "jobsnearme", "label": "jobsnearme CI" } }, with branch to watch another branch than the default. A configured source keeps the bridge up (it doesn't leave after a quiet hour), so its builds are heard when they finish.

State

GET http://127.0.0.1:4747/state answers with every session as the bridge sees it, for the page and anything else (a dashboard) that wants it:

{ "ok": true, "at": 1791050000000, "waitingBy": { "jobsnearme": 4 },
  "lanes": [{ "lane": "a1b2c3d4", "project": "jobsnearme", "label": "jobsnearme · lane-2",
              "state": "needs_ok", "needsReason": "permission", "needsSince": 1791049940000,
              "failing": { "test:vitest": 1791049000000 }, "failingChecks": ["test:vitest"],
              "checklist": { "done": 3, "total": 6 }, "scheduled": false, "external": false,
              "known": true, "openedAt": 1791040000000, "last": 1791049990000, "lastWork": 1791049900000 }] }

state is working, needs_ok (a permission prompt), your_move (a turn ended, or Claude sat idle: needsReason says which) or idle (no work for 15 minutes). A session silent for three minutes is gone. The same picture comes first on /events, so a reloaded page keeps pending calls and broken builds, and the bridge rebuilds it from the logs when it restarts. The logic lives in lib/lanes.mjs, shared by the bridge and the page.

Timesheets

npm run week

The last 7 days (npm run week 14 for more, --json for the numbers) per project, across every machine that syncs coter-kit's brain:

| Column | What it is | | :- | :- | | per day, total | Hours Claude worked on the project (wall clock: parallel sessions count once) | | parallel | The same hours summed over sessions: three lanes for an hour is 3 | | blocked | Time sessions sat on a permission prompt, each wait counted up to an hour | | broken | Time a check (tests, typecheck, lint, build) stayed failing | | commits | Commits made |

Outside sources get their own line: their totals (chats, requests) and how long their errors lasted.

Each machine's bridge boils its own log down to a small summary per day (lib/summary.mjs, a few hundred bytes) and writes it into the brain at undertone/<device>/<date>.json every 30 minutes; coter-kit's next sync carries it to your other machines. Raw logs never leave the machine. GET /timesheet?days=7 serves the same numbers as JSON for a dashboard. The summary is shaped like a database row, so a real database can take over the day home needs queries over months.

Event log

The bridge appends every event to ~/.undertone/log/<date>.jsonl: session starts and heartbeats, prompts, turns, tool calls, commits, failing and passing checks by name, calls for you. It's the raw material for metrics such as time per project, time Claude spent waiting on you, and how long builds stayed broken.

Nothing expires by default: the clips are what teaches Claude to hear, and the logs (up to about half a megabyte on a busy day) are your history. UNDERTONE_EAR_DAYS and UNDERTONE_LOG_DAYS delete clip days and logs older than that many days, on start and daily; pruned clips stay in ear/index.jsonl, marked "pruned": true.

Projects

The Projects panel lists every project with a running session or questions waiting on you. Untick one to silence it: its sessions stop playing, its broken builds and open questions drop out of the mix. The lead project sets the key and tempo; Auto follows the busiest project of the last three minutes and holds still when everything is quiet. The music changes like a DJ set (lib/dj.mjs): another project has to out-work the one playing for over a minute to take the lead, a track plays at least 32 bars, and the change lands on the start of a phrase after two bars of breakdown (drums and pulse out, the pad on a chord both keys share, the tempo gliding), with the new lead's first two call notes on the downbeat. A push or a green CI run from another project is a takeover: its music at full for 16 bars, then back to the lead; from the project playing, the full arrangement for 8 bars; once per project per half hour. What you pick (a lead, a mood) changes within a few bars. Each project can keep its own mood (jobsnearme defaults to bright D major, ourPlace to a warm E♭, Iron Veil to D minor with war drums, wallstbets to A dorian with a ticking hat, undertone to Far shore, coter-kit to Deep field, home to Afterhours, anything else to F major). The page remembers your picks.

A mood can also be a style: a band and a groove of its own, not just a key. Blue hour is the first, a jazz-hop trio in B♭: ii-V-I-VI chords, swung eighths, a walking bass that walks when anyone's busy and drops to half notes when it's quiet, a ride with a cross-stick on 2 and 4, and rootless piano comping (a Rhodes on Sampled, an FM electric piano on Synth) on a different rhythm each bar. Afterhours is house in G minor at 123: four on the floor, a clap on 2 and 4, open hats and the bass on every off-beat, chord stabs, and the pad side-chained to the kick. Far shore (after Mass Effect 1, see docs/space-styles.md) is E minor on open sus2 chords at 100: a pad, a pulse under it that plays only while a turn is running, each session a pulse wave in its own register and place in the stereo field, one dark hall. It arranges in tiers: calm (pad and sessions), working (adds the pulse, a quiet accent and the galaxy-map arpeggio: saw eighths on the chord's tones through a slowly sweeping filter, with a dotted-eighth echo), full (adds a soft tick on 2 and 4 and CS-80-style brass swells on each chord change); up fast, down slow, one layer at a time. The pad breathes, and every other pass through the chords takes richer voicings (a raised fourth, a sus4). Deep field (after Stellaris) is D minor at 90, i–bVII–bVI–v with nothing pulling toward an ending: synth strings, a sine-and-saw ostinato over a pedal that rests now and then, one big hall. Busy thickens it; a commit brings the organ and a low drum in for four bars. From 21:00 to 05:00 Deep field gives way to Night watch, its late-night sibling: slowed to 72 and lowered, a lullaby (i–VI–III–VII) that never comes home, the ostinato in quarter notes, and a soft choir that weaves in for four bars of every eight when the room is busy; the switch comes with a breakdown, so you hear the hour turn. A room flat out for over a minute earns the drive (after "Synthetic God"): two bars of strings and a riser, then a drop to 120 with a kick, a tight sub, the ostinato as a sixteenth sequencer and an off-beat hat, back to 90 when the room calms or after two minutes, then a rest. On Sampled the sessions play a real harp, celesta and cello. Pick any project's mood in the Projects panel. Everything that tells you something (each session's call, the sour notes, the asks) sounds the same in every style.

How it fits together

Claude Code session ──(mod: hooks/register.js)──▶ bridge (127.0.0.1:4747) ──SSE──▶ page (web/index.html): the conductor
Claude Code session ──┘                              │                                 │
coter-kit's brain (open questions) ─────────────────┘                                 ├─▶ Synth / Sampled / Strudel (in the page)
                                                     bridge ◀── notes ─────────────────┘
                                                        └──OSC──▶ SuperCollider (sc/undertone.scd)

| Part | What it is | | :- | :- | | hooks/register.js | The mod, a plugin of function hooks like coter-kit. It turns session events into small JSON events, sends a heartbeat every minute, and counts coter-kit's open questions per project. | | hooks/commands.js | Reads a Bash command for commits, pushes and which checks ran, and whether its exit code is theirs (not through \| tail, \|\| true, ; or &). | | hooks/waiting.js | Counts coter-kit's open questions per project, each question once. | | lib/lanes.mjs | Every session's state, shared by the bridge and the page. | | lib/summary.mjs, bridge/timesheets.mjs | A day of events boiled down to a summary; summaries written to and read from the brain. | | bridge/server.mjs | A zero-dependency Node server on 127.0.0.1. It serves the page and /state, relays events over server-sent events, pings your phone, and talks OSC to SuperCollider. | | bridge/launch.mjs | Starts the bridge detached, the same way on macOS and Windows. | | web/index.html | The conductor and the score. The same file is published as a claude.ai artifact, where it runs the demo on the Synth engine. | | web/engines/*.js | The engines. Each takes the same calls (pad, bass, voice, drum, bell, sour, drone, texture, energy); common.js describes them. | | sc/undertone.scd | SuperCollider's synths, effects and OSC handlers. | | ear/analyze.mjs, ear/gemini.mjs | The ears: clip analysis and spectrograms, and Gemini's critique. | | sources/azure-metrics.mjs | An outside source: Azure Monitor metrics as a lane. | | bridge/sources.mjs | Runs the outside sources as the bridge's child while sources.json describes any. | | tests/undertone.test.ts | Mod tests: claude plugin test . | | tests/unit/, tests/bridge/ | The pure modules, and the bridge run for real: npm test |

coter-kit

undertone is a sibling mod: it never touches coter-kit's code. It hears coter-kit's checklist and remember calls, its checks' stop blocks, and reads the brain's journal (~/.claude/coter-brain, or the brainDir setting) the way coter-kit does to count what's waiting on you. It's listed in coter-kit's marketplace, so it installs alongside it:

claude plugin install undertone@coter-kit

Privacy and security

What leaves the session, to the local bridge: event types, tool names (an MCP tool's server prefix stripped), check names (test, typecheck, lint, build, checks, with a tool such as vitest), project and worktree folder names, counts, and whether the session is a scheduled run. Never prompt text, file paths, command lines or the questions themselves.

What leaves the machine:

| To | What | When | | :- | :- | :- | | Your ntfy server (ntfy.sh by default) | A ping's title (per names), a fixed message, priority and tag | A session waits on you (see Your phone) | | Your brain repo (coter-kit's, private on GitHub) | Each day's summary per project: minutes worked, blocked and broken, counts of sessions, turns, commits and pushes, first and last time of work; outside sources' totals | Written every 30 minutes, carried by coter-kit's sync | | Google (Gemini) | A clip's audio, engine, mood, key, tempo, texture level, reaction kind; its note only with UNDERTONE_GEMINI_NOTE=1 | You press Love this, This grates or Snapshot, with a Gemini key set | | Nowhere else | Outside sources run locally: Azure is read through your own az CLI, and only counts reach the bridge | |

The bridge listens on 127.0.0.1 only and answers only requests addressed to 127.0.0.1:<port> or localhost:<port>, or to a name you list in hosts.json (anything else gets 421), so a web page can't reach it through DNS rebinding. Posts from another origin get

Source 3 files
hooks/register.js 417 lines
1// undertone: plays your Claude Code sessions as music.
2//
3// This mod sends what the session does (turns, tool calls, edits, commits, which checks fail and
4// pass, permission prompts, coter-kit's checklist) to the undertone bridge on 127.0.0.1, which
5// streams it to the page in your browser, along with how many coter-kit questions wait on you in
6// each project. Only event types, tool names, check names (test, typecheck, lint, build), project
7// names, counts and a scheduled-run flag leave the session: never prompt text, file paths or commands.
8//
9//   /undertone          start the bridge if it isn't running and open the page
10//   /undertone status   is the bridge up, and is this session sending
11//   /undertone off|on   stop or resume sending this session's events
12//   /undertone stop     stop the bridge; sessions won't start it again until /undertone or npm start
13//
14// The bridge is simply there: the first session starts it detached (it belongs to no session) and
15// it leaves on its own after a quiet hour. The `bridge` setting "manual" turns that off.
16
17import { analyzeCommand, outcomes } from './commands.js'
18import { countWaiting } from './waiting.js'
19
20const DEFAULT_PORT = 4747
21const CHECKLIST_TOOL = 'mcp__coter-kit__checklist'
22const REMEMBER_TOOL = 'mcp__coter-kit__remember'
23const DOWN_BACKOFF_MS = 15_000
24const HEARTBEAT_MS = 60_000
25const WAITING_EVERY_MS = 120_000
26// While the bridge is down, events wait (heartbeats don't): at most this many, this long
27const HOLD_MAX = 500
28const HOLD_MS = 5 * 60_000
29// A bridge seen this recently that stops answering is started again, at most once a minute
30const RESTART_IF_SEEN_MS = 30 * 60_000
31const RESTART_EVERY_MS = 60_000
32
33let port = DEFAULT_PORT
34let base = 'http://127.0.0.1:' + DEFAULT_PORT
35let lane = null // { lane, project, label }
36let sending = true
37let queue = [] // { e, at } — at is stamped by the first flush that tries to send it
38let flushTimer = null
39let downUntil = 0
40let lastHealthy = 0
41let lastRestartTry = 0
42let autoRestart = true
43let bridgeMode = 'auto' // auto: the first session starts the bridge; manual: only /undertone or npm start
44let checksFailing = false
45let brainDir = ''
46let lastWaiting = ''
47let started = false // timers and the command are set up once per module load
48let scheduled = false // this session is a scheduled run: it plays quietly and never calls you
49let promptSeen = false
50
51function kindOf(tool) {
52  if (/^(Read|Grep|Glob|LS|NotebookRead)$/.test(tool)) return 'read'
53  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'edit'
54  if (/^(Bash|PowerShell)$/.test(tool)) return 'run'
55  if (/^(WebFetch|WebSearch)$/.test(tool) || /browser|chrome/i.test(tool)) return 'web'
56  if (/^(Agent|Task)$/.test(tool)) return 'agent'
57  return 'other'
58}
59
60// mcp__server__tool -> tool, so the page never sees server ids
61const shortTool = (tool) => String(tool || '').split('__').pop().slice(0, 40)
62
63// From the path alone: a worktree under .claude/worktrees/<name> plays as "<project> · <name>"
64function laneFor(root, id) {
65  const parts = String(root || '').replace(/\\/g, '/').replace(/\/+$/, '').split('/')
66  let project = parts[parts.length - 1] || 'session'
67  let label = project
68  const wt = parts.lastIndexOf('worktrees')
69  if (wt > 1 && parts[wt - 1] === '.claude' && parts[wt + 1]) {
70    project = parts[wt - 2] || project
71    label = project + ' · ' + parts[wt + 1]
72  }
73  return { lane: String(id || 'session').slice(0, 8), project, label }
74}
75
76// From git, when it answers: any linked worktree (a sibling folder such as ~/GitHub/EO7-sprites
77// too) plays as "<main checkout> · <worktree folder>"
78const leaf = (p) => p.replace(/\/+$/, '').split('/').pop()
79async function gitNames($, root) {
80  try {
81    const r = await $.process.run(['git', 'rev-parse', '--show-toplevel', '--git-common-dir'], { cwd: root, timeoutMs: 3000 })
82    if (r.exitCode !== 0) return null
83    const [top, common] = String(r.stdout || '').trim().split('\n').map((s) => s.trim().replace(/\\/g, '/'))
84    if (!top || !common) return null
85    const commonAbs = /^(\/|[A-Za-z]:\/)/.test(common) ? common : top.replace(/\/+$/, '') + '/' + common
86    const main = commonAbs.replace(/\/+$/, '').replace(/\/\.git$/, '')
87    if (main === commonAbs.replace(/\/+$/, '')) return null // a bare or unusual layout: trust the path
88    const project = leaf(main)
89    return { project, label: main === top.replace(/\/+$/, '') ? project : project + ' · ' + leaf(top) }
90  } catch {
91    return null
92  }
93}
94
95async function laneNow($) {
96  const root = await $.session.root()
97  const fromPath = laneFor(root, await $.session.id())
98  const fromGit = await gitNames($, root)
99  return fromGit ? { ...fromPath, ...fromGit } : fromPath
100}
101
102function enqueue($, e) {
103  queue.push({ e, at: null })
104  if (!flushTimer) flushTimer = $.clock.after(120, () => { flushTimer = null; return flush($) })
105}
106
107function emit($, type, extra) {
108  if (!lane || !sending) return
109  enqueue($, { type, ...lane, ...(scheduled ? { scheduled: true } : {}), ...extra })
110}
111
112// Batches go out about every 120 ms. While the bridge is down they wait (500 events, 5 minutes)
113// and go out in order once it's back; a bridge that was running is started again.
114async function flush($) {
115  if (!queue.length) return
116  const now = await $.clock.now()
117  for (const q of queue) if (q.at == null) q.at = now
118  queue = queue.filter((q) => now - q.at <= HOLD_MS)
119  if (queue.length > HOLD_MAX) queue.splice(0, queue.length - HOLD_MAX)
120  if (now < downUntil) return
121  const batch = queue
122  queue = []
123  try {
124    const r = await $.http.fetch(base + '/event', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(batch.map((q) => q.e)) })
125    if (!r.ok) throw new Error('bridge answered ' + r.status)
126    lastHealthy = now
127    downUntil = 0
128  } catch {
129    downUntil = now + DOWN_BACKOFF_MS
130    queue = batch.filter((q) => q.e.type !== 'alive').concat(queue)
131    if (autoRestart && (bridgeMode === 'auto' || (lastHealthy && now - lastHealthy < RESTART_IF_SEEN_MS)) && now - lastRestartTry >= RESTART_EVERY_MS) {
132      lastRestartTry = now
133      void ensureBridge($).then((ok) => { if (ok) flush($) })
134    }
135  }
136}
137
138// ---------------------------------------------------------------- waiting on you (coter-kit's brain)
139
140const safeJson = (line) => { try { return JSON.parse(line) } catch { return null } }
141
142async function listDir($, dir) {
143  try {
144    return await $.fs.list(dir)
145  } catch {
146    return []
147  }
148}
149
150async function readText($, path) {
151  try {
152    const r = await $.fs.read(path)
153    return typeof r === 'string' ? r : r && typeof r.text === 'string' ? r.text : ''
154  } catch {
155    return ''
156  }
157}
158
159// Every machine's journal rows; countWaiting (hooks/waiting.js) does the counting
160async function readWaiting($) {
161  let dir = brainDir
162  if (!dir) {
163    const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
164    if (!home) return {}
165    dir = String(home).replace(/\\/g, '/') + '/.claude/coter-brain'
166  }
167  const journal = dir.replace(/\/+$/, '') + '/journal'
168  const rows = []
169  for (const device of await listDir($, journal)) {
170    if (device.kind !== 'dir') continue
171    for (const f of await listDir($, journal + '/' + device.name)) {
172      if (!f.name.endsWith('.jsonl')) continue
173      for (const line of (await readText($, journal + '/' + device.name + '/' + f.name)).split('\n')) {
174        const row = safeJson(line)
175        if (row) rows.push(row)
176      }
177    }
178  }
179  return countWaiting(rows, await $.clock.now())
180}
181
182// Sends the counts when they change (the bridge keeps the newest for pages that connect later)
183async function sendWaiting($, force) {
184  const by = await readWaiting($)
185  const key = JSON.stringify(by)
186  if (key === lastWaiting && !force) return
187  lastWaiting = key
188  emit($, 'waiting', { projects: by, count: Object.values(by).reduce((a, b) => a + b, 0) })
189}
190
191async function healthy($) {
192  try {
193    const r = await $.http.fetch(base + '/health')
194    const ok = r.ok && r.text.includes('"undertone"')
195    if (ok) lastHealthy = await $.clock.now()
196    return ok
197  } catch {
198    return false
199  }
200}
201
202async function undertoneHome($) {
203  const own = await $.env.get('UNDERTONE_HOME')
204  if (own) return String(own).replace(/\\/g, '/')
205  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
206  return home ? String(home).replace(/\\/g, '/') + '/.undertone' : ''
207}
208
209// `/undertone stop` leaves this marker; sessions don't start the bridge again until `/undertone`
210async function stoppedOnPurpose($) {
211  const home = await undertoneHome($)
212  return !!home && (await listDir($, home)).some((f) => f.name === 'stopped')
213}
214
215// Starts a bridge that belongs to no session (bridge/launch.mjs): it outlives whatever started it
216// and leaves on its own after a quiet hour (--auto). `resume` lifts a deliberate /undertone stop.
217async function startDetached($, resume) {
218  const argv = ['node', String($.plugin.root).replace(/\\/g, '/') + '/bridge/launch.mjs', String(port), '--auto', ...(resume ? ['--resume'] : [])]
219  try {
220    const r = await $.process.run(argv, { timeoutMs: 15_000 })
221    return r.exitCode === 0
222  } catch (err) {
223    $.ui.log('undertone: could not start the bridge: ' + (err && err.message ? err.message : err))
224    return false
225  }
226}
227
228async function ensureBridge($, resume) {
229  if (await healthy($)) return true
230  if (!resume && (await stoppedOnPurpose($))) return false
231  if (!(await startDetached($, resume))) return false
232  for (let i = 0; i < 20; i++) {
233    await $.clock.sleep(150)
234    if (await healthy($)) {
235      downUntil = 0
236      return true
237    }
238  }
239  return false
240}
241
242async function openPage($) {
243  const url = base + '/'
244  const windows = (await $.env.get('OS')) === 'Windows_NT'
245  const tries = windows ? [['cmd', '/c', 'start', '', url]] : [['open', url], ['xdg-open', url]]
246  for (const argv of tries) {
247    try {
248      const r = await $.process.run(argv, { timeoutMs: 5000 })
249      if (r.exitCode === 0) return true
250    } catch {}
251  }
252  return false
253}
254
255export function register(on, options) {
256  port = Number(options && options.port) || DEFAULT_PORT
257  base = 'http://127.0.0.1:' + port
258  brainDir = String((options && options.brainDir) || '').replace(/\\/g, '/')
259  bridgeMode = options && options.bridge === 'manual' ? 'manual' : 'auto'
260
261  // Fires on startup, /resume, /clear, compaction and /branch: the lane is refreshed every time,
262  // the timers and the command are set up once
263  on('session.start', async ($, e, next) => {
264    const now = await laneNow($)
265    if (lane && lane.lane !== now.lane) {
266      if (sending) enqueue($, { type: 'session.end', ...lane, ...(scheduled ? { scheduled: true } : {}) })
267      scheduled = false
268      promptSeen = false
269    }
270    lane = now
271    emit($, 'session.start')
272    if (!started) {
273      started = true
274      try {
275        await $.command.register({ name: 'undertone', description: 'Play this session as music: start the bridge and open the page', argumentHint: '[status|off|on|stop]' })
276      } catch (err) {
277        $.ui.log('undertone: could not add /undertone: ' + (err && err.message ? err.message : err))
278      }
279      // A heartbeat, so a session that closes without saying goodbye leaves the page within minutes
280      $.clock.every(HEARTBEAT_MS, () => emit($, 'alive'))
281      $.clock.every(WAITING_EVERY_MS, () => sendWaiting($))
282    }
283    $.clock.after(1500, () => sendWaiting($, true))
284    // The first session starts the bridge, off the session's own startup path
285    if (bridgeMode === 'auto' && sending) {
286      $.clock.after(0, async () => {
287        if (await healthy($)) return
288        lastRestartTry = await $.clock.now()
289        if (await ensureBridge($)) { downUntil = 0; await flush($) }
290      })
291    }
292    return next(e)
293  })
294
295  on('session.end', async ($, e, next) => {
296    emit($, 'session.end')
297    await flush($)
298    return next(e)
299  })
300
301  on('prompt.submit', async ($, e, next) => {
302    // A scheduled task's run starts with its <scheduled-task …> prompt. Only the flag leaves.
303    if (!promptSeen) {
304      promptSeen = true
305      if (/^\s*<scheduled-task[\s>]/.test(String(e.text || ''))) scheduled = true
306    }
307    emit($, 'prompt')
308    return next(e)
309  })
310
311  on('turn.start', async ($, e, next) => {
312    emit($, 'turn.start')
313    return next(e)
314  })
315
316  on('turn.complete', async ($, e, next) => {
317    if (!e.agentId) emit($, 'turn.end')
318    return next(e)
319  })
320
321  // Claude Code asking for you: a permission prompt, or idle and waiting for input
322  on('classic.Notification', async ($, e, next) => {
323    const kind = String(e.notification_type || '') + ' ' + String(e.message || '')
324    const reason = /permission|elicitation|approve/i.test(kind) ? 'permission' : /idle|waiting for your input/i.test(kind) ? 'idle' : null
325    if (reason) emit($, 'attention', { reason })
326    return next(e)
327  })
328
329  on('tool.call', async ($, e, next) => {
330    // coter-kit's own tools answer without calling next, so read them on the way in
331    if (e.tool === CHECKLIST_TOOL && Array.isArray(e.items)) {
332      emit($, 'checklist', { done: e.items.filter((it) => it && it.status === 'done').length, total: e.items.length })
333    }
334    if (e.tool === REMEMBER_TOOL && Array.isArray(e.notes)) {
335      for (const n of e.notes) {
336        if (n && n.kind === 'waiting') emit($, 'waiting.open')
337        else if (n && n.kind === 'decision' && n.resolves) emit($, 'waiting.resolve')
338      }
339      // coter-kit writes the note after this call; read the new counts once it has
340      $.clock.after(2000, () => sendWaiting($))
341    }
342
343    const result = await next(e)
344    if (!result || result.deny) return result
345
346    emit($, 'tool', { tool: shortTool(e.tool), kind: kindOf(e.tool) })
347    // Commits, pushes and which checks passed or failed (hooks/commands.js); never the command
348    if (e.tool === 'Bash' && typeof e.command === 'string') {
349      for (const o of outcomes(analyzeCommand(e.command), !result.isError)) {
350        emit($, o.type, o.check ? { check: o.check, source: 'command' } : {})
351      }
352    }
353    return result
354  })
355
356  // coter-kit's checks block the stop when they fail; a clean stop after that means they pass
357  on('classic.Stop', async ($, e, next) => {
358    const result = await next(e)
359    if (result && result.block) {
360      checksFailing = true
361      emit($, 'fail', { check: 'checks', source: 'checks' })
362    } else if (checksFailing) {
363      checksFailing = false
364      emit($, 'pass', { check: 'checks', source: 'checks' })
365    }
366    return result
367  })
368
369  on('command.run', { command: 'undertone' }, async ($, e) => {
370    const arg = String(e.args || '').trim().toLowerCase()
371
372    if (arg === 'off') {
373      emit($, 'session.end')
374      await flush($)
375      sending = false
376      return { text: 'undertone: this session stopped sending events. `/undertone on` brings it back.' }
377    }
378    if (arg === 'on') {
379      sending = true
380      downUntil = 0
381      emit($, 'session.start')
382      return { text: 'undertone: this session is sending events again.' }
383    }
384    if (arg === 'status') {
385      const up = await healthy($)
386      return {
387        text: 'undertone: bridge ' + (up ? 'running at ' + base : 'not running') +
388          '; this session (' + (lane ? lane.label : '?') + ') is ' + (sending ? 'sending' : 'not sending') + ' events' +
389          (scheduled ? ' as a scheduled run' : '') + (autoRestart ? '' : '; restarting the bridge is off') + '.',
390      }
391    }
392    if (arg === 'stop') {
393      autoRestart = false
394      if (!(await healthy($))) return { text: 'undertone: no bridge is running. This session won\'t start one until `/undertone`.' }
395      let r = null
396      try {
397        r = await $.http.fetch(base + '/shutdown', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sticky: true }) })
398      } catch {}
399      // A bridge from before 0.6 has no /shutdown
400      if (!r || !r.ok) return { text: 'undertone: the bridge at ' + base + ' did not stop (it may be older than 0.6). Stop it where it runs (Ctrl-C, or end its process). This session won\'t restart it.' }
401      return { text: 'undertone: bridge stopped. Sessions won\'t start it again until `/undertone` (or `npm start`).' }
402    }
403
404    autoRestart = true
405    if (!(await ensureBridge($, true))) {
406      return { text: 'undertone: the bridge did not start. Run `npm start` in ' + $.plugin.root + ' to see why.' }
407    }
408    downUntil = 0
409    const opened = await openPage($)
410    emit($, 'session.start')
411    return {
412      text: 'undertone is running at ' + base + (opened ? ' (opened in your browser)' : '') +
413        '. Press Play on the page. This session plays as "' + (lane ? lane.label : 'session') + '".',
414    }
415  })
416}
417
hooks/commands.js 162 lines
1// What a Bash command did, as far as undertone cares: did it commit, did it push, which checks
2// (tests, typecheck, lint, build) did it run, and can its exit code be trusted as theirs.
3// Pure; only booleans and short check names come out, never the command.
4//
5//   analyzeCommand(cmd)        -> { commit, push, checks: [{ check, reportable }] }
6//   outcomes(analysis, ok)     -> the events to send: commit, push, pass/fail with `check`
7
8const base = (w) => String(w || '').split('/').pop()
9
10// Heredoc bodies are data, not commands: drop the lines up to the closing word
11function stripHeredocs(cmd) {
12  const out = []
13  let end = null
14  for (const line of String(cmd).split('\n')) {
15    if (end) { if (line.trim() === end) end = null; continue }
16    out.push(line)
17    const m = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line)
18    if (m) end = m[2]
19  }
20  return out.join('\n')
21}
22
23// Splits on unquoted && || ; | & and newlines. Quoted strings are emptied (so a commit message
24// can't look like a command) and comments dropped. Each segment remembers the operator after it.
25function segments(cmd) {
26  const segs = []
27  let cur = ''
28  let q = null
29  const cut = (op) => { if (cur.trim()) segs.push({ text: cur.trim(), op }); else if (segs.length && op) segs[segs.length - 1].op = segs[segs.length - 1].op || op; cur = '' }
30  for (let i = 0; i < cmd.length; i++) {
31    const c = cmd[i]
32    const n = cmd[i + 1]
33    if (q) {
34      if (c === '\\' && q === '"') { i++; continue }
35      if (c === q) { q = null; cur += '""' }
36      continue
37    }
38    if (c === "'" || c === '"') { q = c; continue }
39    if (c === '\\') { cur += n || ''; i++; continue }
40    if (c === '#' && (i === 0 || /\s/.test(cmd[i - 1]))) { while (i + 1 < cmd.length && cmd[i + 1] !== '\n') i++; continue }
41    if (c === '\n' || c === ';') { cut(';'); continue }
42    if (c === '&' && n === '&') { cut('&&'); i++; continue }
43    if (c === '|' && n === '|') { cut('||'); i++; continue }
44    if (c === '|') { cut('|'); if (n === '&') i++; continue }
45    if (c === '&' && n !== '>' && cmd[i - 1] !== '>' && cmd[i - 1] !== '<') { cut('&'); continue }
46    cur += c
47  }
48  cut(null)
49  return segs
50}
51
52const WRAPPERS = new Set(['time', 'env', 'sudo', 'nohup', 'command', 'exec'])
53function words(text) {
54  const ws = text.split(/\s+/).filter(Boolean)
55  while (ws.length && (WRAPPERS.has(ws[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(ws[0]))) ws.shift()
56  return ws
57}
58
59function scriptCheck(script) {
60  const s = String(script || '').toLowerCase()
61  if (/^tests?($|:)/.test(s) || s === 'e2e') return 'test'
62  if (/typecheck|type-check|^tsc$|^types$/.test(s)) return 'typecheck'
63  if (/^lint/.test(s)) return 'lint'
64  if (/^build/.test(s)) return 'build'
65  if (s === 'check') return 'checks'
66  return null
67}
68
69// The check a segment runs, as "<family>" or "<family>:<tool>" when the tool says something
70function classify(ws) {
71  const cmd = base(ws[0])
72  const rest = ws.slice(1)
73  const sub = rest.find((w) => !w.startsWith('-'))
74  switch (cmd) {
75    case 'npm': case 'pnpm': case 'yarn': case 'bun': {
76      if (cmd === 'bun' && rest[0] === 'test') return 'test:bun'
77      let script = rest[0]
78      if (script === 'run' || script === 'run-script') script = rest.slice(1).find((w) => !w.startsWith('-'))
79      if (cmd === 'npm' && script === 't') script = 'test'
80      return scriptCheck(script)
81    }
82    case 'npx': case 'pnpx': case 'bunx': {
83      const i = rest.findIndex((w) => !w.startsWith('-'))
84      return i < 0 ? null : classify(rest.slice(i))
85    }
86    case 'tsc': return 'typecheck:tsc'
87    case 'jest': case 'vitest': case 'mocha': case 'playwright': case 'pytest': return 'test:' + cmd
88    case 'mypy': return 'typecheck:mypy'
89    case 'eslint': return 'lint:eslint'
90    case 'ruff': return sub === 'check' ? 'lint:ruff' : null
91    case 'python': case 'python3': {
92      const i = rest.indexOf('-m')
93      const mod = i >= 0 ? rest[i + 1] : null
94      return { pytest: 'test:pytest', unittest: 'test:unittest', mypy: 'typecheck:mypy', py_compile: 'typecheck:python' }[mod] || null
95    }
96    case 'dotnet': return { build: 'build:dotnet', test: 'test:dotnet' }[sub] || null
97    case 'cargo': return { build: 'build:cargo', test: 'test:cargo', check: 'typecheck:cargo', clippy: 'lint:cargo' }[sub] || null
98    case 'go': return { build: 'build:go', test: 'test:go', vet: 'lint:go' }[sub] || null
99    case 'make': {
100      const target = rest.find((w) => !w.startsWith('-') && !w.includes('='))
101      if (!target) return 'build'
102      return { test: 'test', check: 'checks', build: 'build', lint: 'lint', typecheck: 'typecheck' }[target] || null
103    }
104    case 'claude': return rest[0] === 'plugin' && rest[1] === 'test' ? 'test:claude' : null
105    case 'node': return rest.includes('--test') ? 'test:node' : null
106    default: return null
107  }
108}
109
110// git's subcommand after its global options (-C dir, -c k=v, --no-pager, ...)
111function gitSub(ws) {
112  if (base(ws[0]) !== 'git') return null
113  for (let i = 1; i < ws.length; i++) {
114    const w = ws[i]
115    if (w === '-C' || w === '-c' || w === '--git-dir' || w === '--work-tree' || w === '--namespace') { i++; continue }
116    if (w.startsWith('-')) continue
117    return { sub: w, args: ws.slice(i + 1) }
118  }
119  return null
120}
121
122export function analyzeCommand(cmd) {
123  const segs = segments(stripHeredocs(cmd))
124  const result = { commit: false, push: false, checks: [] }
125  let pipefail = false
126  for (let k = 0; k < segs.length; k++) {
127    const ws = words(segs[k].text)
128    if (!ws.length) continue
129    if (/\bpipefail\b/.test(segs[k].text)) pipefail = true
130    const g = gitSub(ws)
131    if (g && g.sub === 'commit' && !g.args.includes('--dry-run')) result.commit = true
132    if (g && g.sub === 'push' && !g.args.includes('--dry-run') && !g.args.includes('-n')) result.push = true
133    const check = classify(ws)
134    if (!check) continue
135    // The command's exit code is this check's only if nothing after it can mask it: from here to
136    // the end only `&&` (and a pipe when pipefail is set). `;`, `||` (`|| true`), a plain pipe
137    // (`| tail`) or `&` (background) all hide it.
138    let reportable = true
139    for (let j = k; j < segs.length - 1; j++) {
140      const op = segs[j].op
141      if (op === '&&') continue
142      if (op === '|' && pipefail) continue
143      reportable = false
144      break
145    }
146    if (segs[segs.length - 1].op === '&') reportable = false
147    result.checks.push({ check, reportable })
148  }
149  return result
150}
151
152// A failure with more than one check in the chain can't say which failed, so it says nothing
153export function outcomes(a, ok) {
154  const out = []
155  if (ok && a.commit) out.push({ type: 'commit' })
156  if (ok && a.push) out.push({ type: 'push' })
157  const checks = a.checks.filter((c) => c.reportable)
158  if (ok) for (const c of checks) out.push({ type: 'pass', check: c.check })
159  else if (checks.length === 1) out.push({ type: 'fail', check: checks[0].check })
160  return out
161}
162
hooks/waiting.js 40 lines
1// How many coter-kit questions wait on you, per project, from the brain's journal rows.
2// Pure, so it can be tested without a brain on disk.
3//
4// Read the way coter-kit reads them: status rows applied in time order, unanswered questions
5// dropped after 30 days. On top of that, the same question saved twice (two devices, a minute
6// apart) counts once. Only `waiting` notes count: never threads (coter-kit 0.6.1 saves every
7// question as a waiting note) and never handoffs.
8//
9// coter-kit plans a live status file (its build plan, M2). When that ships, read it instead of
10// the journal; until then this mirrors coter-kit's own reader.
11
12const TTL_MS = 30 * 86_400_000
13const norm = (t) => String(t || '').trim().toLowerCase().replace(/\s+/g, ' ')
14
15export function countWaiting(rows, now) {
16  const entries = new Map()
17  const ops = []
18  for (const row of rows) {
19    if (!row || typeof row !== 'object') continue
20    if (row.op) ops.push(row)
21    else if (row.id && row.kind) entries.set(row.id, row)
22  }
23  ops.sort((a, b) => a.at - b.at)
24  for (const op of ops) {
25    const e = entries.get(op.ref)
26    if (e && op.op === 'status') entries.set(op.ref, { ...e, status: op.status, updatedAt: op.at })
27  }
28  const seen = new Set()
29  const by = {}
30  for (const e of entries.values()) {
31    if (e.kind !== 'waiting' || e.status !== 'active' || now - (e.updatedAt || e.at) > TTL_MS) continue
32    const project = e.project || 'every project'
33    const key = project + '\u0000' + norm(e.text)
34    if (seen.has(key)) continue
35    seen.add(key)
36    by[project] = (by[project] || 0) + 1
37  }
38  return by
39}
40