ND-friendly mods for Claude Code: patch status line and friends.

Claude Code mods for brains that wander.
Neurodivergent-friendly mods for Claude Code that keep your work visible and your wins audible. A status line shows the patch you're on, a chime celebrates when it's done, a gentle clock nudges you to check in with your body on long sessions, and a re-entry card tells you where you left off. CodyND helps and celebrates. It never polices.
| Mod | What it does |
|---|---|
| Patch status line | Shows 🩹 <title> · 24m under the prompt for the ChaosPatch patch in progress in this repo ((+N) if there are more). The time reads <1m, 24m, 1h 10m, then whole days (3d), redrawn once a minute. Patches from other projects stay quiet. With no patch or focus, the line shows your current work stretch instead (⏱ 45m in, the same clock as the body check), hidden while you're on a break, so it never counts up while you're away. |
| Done chime | When a patch is completed, a 10-second toast (🎉 Patch done: <title>) and a short chime. Each session's first win gets the classic two-note chime; after that the sound and the toast's opener rotate (✨ Shipped, 🌱 One less thing, …), never the same twice running, so the reward stays fresh. If the tree is fully committed and the session's latest test run (if any) passed, a second toast follows: 🟢 Clean stopping point. Safe to walk away. Otherwise it stays silent; it never runs tests itself. Failed calls don't celebrate. Other CodyND toasts that arrive meanwhile wait their turn, so the win is never covered up. A patch sent to review (cp_request_review) gets its own rising two-note chime and 👀 Ready for review: <title>, with no stopping-point toast, since you still have a look to take. |
| Attention chime | A short rising "hey?" sound whenever Claude is waiting on you: a permission prompt, a question, or an MCP server asking for input. Not when Claude has just finished and you've been away; that isn't a decision. Prompts that land together chime once. |
| Patch soundtrack | /soundtrack on and starting a patch puts on focus music: Spotify's Deep Focus, or your own playlist in settings. If something's already playing, it's left alone. When the patch is done or sent to review, music the soundtrack started fades out over about a second, under the chime, and pauses. Off until you turn it on, and it stays how you left it across sessions. Drives the Spotify app on macOS and never opens it; if Spotify isn't running, nothing happens. Works from /patches and /fried too. Headless runs never touch the music. |
| Error decoder | When a build, typecheck, test or lint run fails, one toast says what broke in plain English: 🧩 TypeScript can't find \clock\, probably a missing import or a typo (in wins.ts:81). Common failures (TypeScript errors, missing modules, failing tests, lint counts, syntax errors) are read straight from the output; anything else gets one quick Haiku sentence. The same error again within 5 minutes stays quiet, it waits behind a done toast, and commands like grep that just return non-zero never trigger it. |
| Instruction hooks | Quiet reminders for Claude, never for you, so the rules in your instructions don't fade in a long session. Every conversation starts with the rules in rules.md (one file, plain text, edit it freely). The first file edit with no patch in progress and no focus set carries a note asking Claude to start the patch or set a focus. It comes back after a topic switch: the focus changes or clears, or a patch is completed or sent to review. Completing a patch without a completion note gets a nudge to add one; sending one to review without a note gets a nudge to say what to check. None of it shows on your screen. |
| Focus slot | When no patch is in progress, the status line shows 🎯 <focus>: a short label for the non-patch work at hand. Claude sets it with its set_focus / clear_focus tools when the topic changes; /topic <label> sets it by hand, bare /topic asks Claude to name the current work and set it, and /topic clear clears it. A patch always wins the line; the focus clears when the session ends. When a focus is replaced by a new one, a small band offers to park the old one in Kindling (Park / Dismiss). It goes away with your next prompt, and each label is offered once a session. |
| Next-event countdown | Your next ControlledChaos event leads the status line, ahead of the patch or focus: 📚 Latin in 40m │ 🩹 <patch>, so a long patch title is what gets trimmed on a narrow terminal, never the event. Only timed events you've committed to count (not all-day or tentative ones, not planned work blocks), and only within the next 3 hours. At 15 minutes the emoji turns 🟠, one toast says 🛬 <event> in 15m. Start landing the plane., and an airline cabin "bing-bong" rings once. Fetched every 5 minutes, ticked each minute; if ControlledChaos can't be reached, the segment just isn't there. |
| Session clock | After 90 minutes of continuous prompting: 🫖 1h 30m in — water? food? stretch?. One toast per stretch, gone after 15 seconds. 20 minutes without a prompt counts as a break and resets the clock. /snooze [minutes] pushes it out (default 30). |
| Re-entry card | A band above the prompt when a session opens: your /wrap note ("Left off"), your last commit ("Last time") and the patch in progress, or the top open one ("Up next"). Disappears on Dismiss or your first prompt. /recap brings it back, freshly loaded, when you return to a window you left open. |
/wrap | /wrap <note> leaves a where-I-left-off note for this repo; the next session's re-entry card shows it (Left off: <note> · 2h ago). Bare /wrap has Cody write it for you: about 50 words on what got done, where things stand, and the next step, led by the current patch or focus (just the patch or focus if that can't be written). Also mentions uncommitted files, as information only. One note per repo, kept on this machine. |
/park | /park <thought> saves a side-thought to Kindling, tagged parked and project:<repo>, and confirms with a toast. Tangents get caught, not policed. It runs the moment you hit Enter, even while Claude is mid-turn. If it can't save, the thought is echoed back so it isn't lost. |
/patch | /patch <rough idea> turns an "oh, we should also…" into a real ChaosPatch patch for this repo: a title, a one-line summary and acceptance criteria, drafted from the session so it knows what you're working on. Runs mid-turn without interrupting. Tagged quick-capture, plus a guessed energy:low/energy:med/energy:high for /fried; your exact words are kept in the patch's spec, and if drafting fails your raw words are filed, tagged rough. |
/wins | Today's wins in this repo: patches closed and commits made since local midnight. /wins-all shows closed patches across every project, and /wins-week shows every project's closes since Monday with the total and the busiest day. An empty day gets a gentle line, never a guilt trip. |
/whatchanged | What this session changed, without reading diffs: 1–3 plain-language lines on what the code does now, the files touched, and a heads-up for anything worth a second look (dependencies, config, auth, database, deletions). It compares against where the session started, commits and uncommitted work alike. The summary comes from a side call that knows the session, so it doesn't add to the conversation. /whatchanged patch keeps it to the patch in progress; other words name a patch. Nothing changed yet? It says so kindly. |
/patches | Opens this repo's ChaosPatch board in a pane: in-progress patches with Done, patches awaiting review with Done (approve), open ones by priority with Start and → Cody, which hands the patch to Claude as a prompt (number keys press it). Done plays the chime. Runs right away, even mid-answer. If ChaosPatch can't be reached it retries once, then says why. Esc closes. |
/fried | For tired evenings when your brain still wants a goal: a pane of open patches tagged energy:low, across every project, by priority. Start works on any of them; → Cody shows on this repo's only. Nothing tiny queued? It says so, and suggests rest. |
| Toolbox manifest | Writes what's installed on this machine (skills, plugins, MCP servers, CodyND's mods) to ~/ChaosToolbox/toolbox-manifest.json, for a claude.ai routine that can't read ~/.claude. It runs at session start, at most once every 6 hours, and on demand with /toolbox-sync. Names and descriptions only: no env values, headers, tokens or full URLs (a server's host at most). |
CodyND is a Claude Code plugin of function hooks (a "mod"). Clone it, then load it one of three ways.
git clone https://github.com/lmdrew96/codynd
Every session (settings). Add the folder to the env block of ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/codynd" }
One session. Start Claude Code with the folder:
claude --plugin-dir /path/to/codynd
Skills folder. Claude Code also loads a plugin it finds in ~/.claude/skills/<name>:
git clone https://github.com/lmdrew96/codynd ~/.claude/skills/codynd
Pick one. Loading it two ways at once loads it twice.
I've used the first two. The skills-folder route comes from Claude Code's plugin docs; I haven't run it myself yet.
/patches, /fried, /patch, /wins, and the card's "Up next" line. Without one those stay quietly empty; the session clock and the card's "Last time" line still work. A project matches a repo when its slug or name equals the repo's folder name, ignoring case and punctuation (chicken-scratch = Chicken Scratch). For a folder named something else, add it to projectAliases./park./patch, bare /wrap, and /whatchanged make one model call per use: a fork of your session, on the session's model, mostly served from the prompt cache. The error decoder makes one small Haiku call per failed run. Every other mod runs without model calls.afplay; elsewhere you get the toast only. The soundtrack needs the Spotify desktop app; the first time, macOS asks to let Claude Code control Spotify.Change these in /config (they're stored under pluginConfigs.codynd in your settings).
| Setting | Default | What it does |
|---|---|---|
chaospatchServer | claude.ai ChaosPatch | Your ChaosPatch MCP server's name, as /mcp lists it. |
projectAliases | (empty) | Repos whose folder name doesn't match their ChaosPatch project: folder=slug pairs, comma-separated (ADHD-AgenticDevHumanDesigns=adhdesigns). |
doneChimeSound | true | Play the chime when a patch is completed or sent to review. |
landingChimeSound | true | Play the cabin chime with the 15-minute heads-up before your next event. |
attentionChimeSound | true | Play the attention chime when Claude needs you. |
soundtrackPlaylist | Spotify's Deep Focus | What the soundtrack plays when a patch starts: a spotify: URI or an open.spotify.com link. |
soundtrackFadeOnDone | true | Fade out and pause the soundtrack's music when a patch is completed. |
bodyCheckMinutes | 90 | Minutes of continuous work before the body-check nudge. |
kindlingServer | claude.ai Kindling | Your Kindling MCP server's name, for /park. |
controlledChaosServer | claude.ai ControlledChaos | Your ControlledChaos MCP server's name, for the next-event countdown. |
| Command | What it does | |
|---|---|---|
/patches | Open this repo's ChaosPatch board. | |
/fried | Open only the low-energy patches, across projects. | |
| `/topic [label \ | clear]` | Set the status-line focus by hand; alone, have Claude name the work; clear clears it. |
/wrap [note] | Leave a where-I-left-off note for next session. | |
/recap | Bring the re-entry card back up, freshly loaded. | |
/patch <idea> | Draft and file a patch for this repo from a rough idea. | |
/wins | Today's closed patches and commits in this repo. | |
/wins-all | Today's closed patches across every project. | |
/wins-week | This week's closed patches across every project, since Monday 00:00 local. | |
/whatchanged [patch] | This session's changes in plain language, with files and a heads-up. | |
/park <thought> | Park a side-thought in Kindling. | |
| `/soundtrack [on \ | off]` | Turn the patch soundtrack on or off; alone, say which it is. |
/snooze [minutes] | Push the next body-check nudge out (default 30 minutes). | |
/toolbox-sync | Rewrite the toolbox manifest now. |
Edits hot-reload in a session that loaded the folder with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS.
pnpm install # TypeScript, for type-checking
pnpm typecheck # tsc -p .
pnpm test # claude plugin test .
pnpm validate # claude plugin validate .
Claude Code writes the API's type declarations into .claude-plugin/types/ when it loads the mod. That folder is gitignored and is the authority on the API for your build.
.claude-plugin/plugin.json manifest and settings (userConfig)
hooks/register.ts wires the mods together
hooks/next-event.ts next-event countdown
hooks/patch-status.ts status line and patch timer
hooks/focus.tsx focus slot (set_focus / clear_focus tools, /topic, park offer)
hooks/toast-queue.ts holds toasts behind a done toast
hooks/done-chime.ts done chime
hooks/soundtrack.ts patch soundtrack, /soundtrack
hooks/attention-chime.ts attention chime
hooks/error-decoder.ts error decoder toast
hooks/session-clock.ts session clock and /snooze
hooks/reentry-card.tsx re-entry card, /recap
hooks/park.ts /park
hooks/patches-pane.tsx /patches board, /fried
hooks/quick-patch.ts /patch
hooks/wins.ts /wins, /wins-all and /wins-week
hooks/wrap.ts /wrap
hooks/whatchanged.ts /whatchanged
hooks/toolbox.ts toolbox manifest, /toolbox-sync
hooks/instructions.ts instruction hooks (rules block, patch-or-focus and completion-note reminders)
rules.md the rule list every conversation starts with
hooks/*.test.ts tests (claude plugin test)
types/index.d.ts $.state contract (card, board, active patches, focus, park offer)
sounds/*.wav the chimes (original synthesized clips; done.wav is the classic, attention.wav the yoo-hoo, landing.wav the cabin chime)
MIT. Part of ADHDesigns.
hooks/register.ts 41 lines1import type { Register } from 'claude-code'
2import { registerAttentionChime } from './attention-chime.ts'
3import { registerDoneChime } from './done-chime.ts'
4import { registerErrorDecoder } from './error-decoder.ts'
5import { registerFocus } from './focus.tsx'
6import { registerInstructions } from './instructions.ts'
7import { registerNextEvent } from './next-event.ts'
8import { registerPark } from './park.ts'
9import { registerPatchStatus } from './patch-status.ts'
10import { registerPatchesPane } from './patches-pane.tsx'
11import { registerQuickPatch } from './quick-patch.ts'
12import { registerReentryCard } from './reentry-card.tsx'
13import { registerSessionClock } from './session-clock.ts'
14import { registerSoundtrack } from './soundtrack.ts'
15import { registerToastQueue } from './toast-queue.ts'
16import { registerToolbox } from './toolbox.ts'
17import { registerWhatChanged } from './whatchanged.ts'
18import { registerWins } from './wins.ts'
19import { registerWrap } from './wrap.ts'
20
21export const register: Register = (on, options) => {
22 registerPatchStatus(on, options)
23 registerDoneChime(on, options)
24 registerSoundtrack(on, options)
25 registerAttentionChime(on, options)
26 registerErrorDecoder(on)
27 registerSessionClock(on, options)
28 registerReentryCard(on, options)
29 registerPark(on, options)
30 registerPatchesPane(on, options)
31 registerWins(on, options)
32 registerQuickPatch(on, options)
33 registerToastQueue(on)
34 registerFocus(on, options)
35 registerWrap(on)
36 registerWhatChanged(on)
37 registerNextEvent(on, options)
38 registerInstructions(on)
39 registerToolbox(on)
40}
41hooks/attention-chime.ts 48 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3// #21 Attention chime: a "yoo-hoo!" when Cody needs Nae: a permission prompt, a question, or an
4// MCP server asking for input. Not on idle_prompt: "done, and you've been away" isn't a decision.
5// Built against Claude Code 2.1.289.
6
7export const ATTENTION_CHIME = 'sounds/attention.wav'
8// Claude Code's Notification types that mean "waiting on you to decide something".
9export const ATTENTION_TYPES = ['permission_prompt', 'elicitation_dialog', 'elicitation_url_dialog', 'agent_needs_input'] as const
10// A question that also raises a permission notification, or parallel prompts, chime once.
11export const QUIET_MS = 3_000
12// The docs don't say whether AskUserQuestion raises a Notification, so it's hooked directly.
13const QUESTION_TOOL = /^AskUserQuestion$/
14
15export const needsAttention = (notificationType: string): boolean =>
16 (ATTENTION_TYPES as readonly string[]).includes(notificationType)
17
18type Box = { lastChimeAt: number | undefined }
19
20export const isQuiet = (box: Box, now: number): boolean => box.lastChimeAt !== undefined && now - box.lastChimeAt < QUIET_MS
21
22// Detached by its callers: a prompt never waits on the sound.
23const chime = async ($: EngineInterface, box: Box): Promise<void> => {
24 try {
25 const now = await $.clock.now()
26 if (isQuiet(box, now)) return
27 box.lastChimeAt = now
28 await $.audio.play({ asset: ATTENTION_CHIME })
29 } catch (err) {
30 $.ui.log(`attention-chime: sound failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
31 }
32}
33
34export const registerAttentionChime = (on: On, options: PluginOptions): void => {
35 if (options.attentionChimeSound === false) return
36 const box: Box = { lastChimeAt: undefined }
37
38 on('classic.Notification', async ($, e, next) => {
39 if (needsAttention(e.notification_type)) void chime($, box)
40 return next(e)
41 })
42
43 // Before next: the question shows, and waits for Nae, inside it.
44 on('tool.call', { tool: QUESTION_TOOL }, async ($, e, next) => {
45 void chime($, box)
46 return next(e)
47 })
48}hooks/done-chime.ts 164 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { Celebration } from '../types'
4import { truncate } from './patch-status.ts'
5
6// #2 Done chime: a toast (and optional sound) when a ChaosPatch patch is completed.
7// #8 Chime variety: the sound and the toast's opener rotate, never the same twice running.
8// Clean stopping point: after the win, permission to stop when the tree is committed and the
9// last test run passed. Silent otherwise; never a nag.
10// Review: a patch sent to review (cp_request_review) gets its own chime and toast. Cody's part is
11// done but Nae still checks it, so no stopping-point toast.
12// Built against Claude Code 2.1.289.
13
14const COMPLETE_TOOL = /__cp_complete_patch$/
15const REVIEW_TOOL = /__cp_request_review$/
16const BASH_TOOL = /^Bash$/
17// Original synthesized clips. done.wav comes first: a session's first win is always the classic.
18export const CHIMES = ['sounds/done.wav', 'sounds/arpeggio.wav', 'sounds/marimba.wav', 'sounds/blip-ding.wav'] as const
19// A rising fourth, unlike any done chime: "take a look?" rather than "finished".
20export const REVIEW_CHIME = 'sounds/review.wav'
21// patches-pane.tsx shares this through the same atom, so a Done there counts as the last pick too.
22const lastCelebration = atom({ plugin: 'codynd', key: 'lastCelebration' } as const, null)
23// null until a test command runs this session; then whether the latest one failed.
24const lastTestFailed = atom({ plugin: 'codynd', key: 'lastTestFailed' } as const, null)
25
26export const STOP_TOAST = '🟢 Clean stopping point. Safe to walk away.'
27
28// Test runners Cody runs through Bash, anywhere in the command (cd … && pnpm test | tail). Bare
29// runner names count only as a command, so `cat vitest.config.ts` isn't a test run.
30const TEST_COMMAND =
31 /\b(?:(?:pnpm|npm|yarn|bun)(?:\s+-\S+)*\s+(?:run\s+)?test|npx\s+(?:vitest|jest)|cargo\s+test|go\s+test|claude\s+plugin\s+test)\b|(?:^|[;&|]\s*)(?:vitest|jest|pytest)\b/
32// A pipe (| tail) hides the exit code, so the runner's own summary counts too: "3 fail", "1 failed".
33const FAIL_SUMMARY = /\b[1-9]\d*\s+(?:fail|failed|failing|failures?)\b/i
34
35export const isTestCommand = (command: string): boolean => TEST_COMMAND.test(command)
36
37export const testRunFailed = (isError: boolean, output: string): boolean => isError || FAIL_SUMMARY.test(output)
38
39// Core's text when it set it; else Bash's own result, a string or { stdout, stderr }.
40// error-decoder.ts reads Bash output the same way.
41export const outputOf = (ran: { text?: string; result?: unknown }): string => {
42 if (ran.text !== undefined) return ran.text
43 if (typeof ran.result === 'string') return ran.result
44 const r = (ran.result ?? {}) as { stdout?: unknown; stderr?: unknown }
45 return [r.stdout, r.stderr].filter((s): s is string => typeof s === 'string').join('\n')
46}
47
48// Clean: nothing uncommitted, and no failing test run this session (none run counts as fine).
49export const isCleanStop = (porcelain: string, testFailed: boolean | null): boolean =>
50 porcelain.trim() === '' && testFailed !== true
51
52const cleanStop = async ($: EngineInterface): Promise<boolean> => {
53 try {
54 const git = await $.process.run(['git', 'status', '--porcelain'])
55 return git.exitCode === 0 && isCleanStop(git.stdout, await read($, lastTestFailed))
56 } catch (err) {
57 $.ui.log(`done-chime: stopping-point check failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
58 return false
59 }
60}
61// Toasts can't be sticky (only a timeout), so the win stays up long enough to actually see.
62export const DONE_TOAST_MS = 10_000
63
64// cp_complete_patch and cp_request_review answer with the patch as JSON; anything else gets the generic toast.
65export const patchTitle = (text: string | undefined): string | undefined => {
66 if (text === undefined) return undefined
67 try {
68 const parsed: unknown = JSON.parse(text)
69 const title = (parsed as { title?: unknown } | null)?.title
70 return typeof title === 'string' ? title : undefined
71 } catch {
72 return undefined
73 }
74}
75
76// Openers for the done toast; the first is the classic. toast-queue.ts recognizes done toasts by them.
77export const DONE_OPENERS = ['🎉 Patch done', '✨ Shipped', '🌱 One less thing', '🏁 Done and dusted', '💥 Knocked out'] as const
78
79export const REVIEW_OPENER = '👀 Ready for review'
80
81// Done and review toasts both hold the line in toast-queue.ts.
82export const isDoneToast = (text: string): boolean =>
83 text.startsWith(REVIEW_OPENER) || DONE_OPENERS.some(opener => text.startsWith(opener))
84
85export const reviewMessage = (title: string | undefined): string =>
86 title === undefined ? `${REVIEW_OPENER}!` : `${REVIEW_OPENER}: ${truncate(title)}`
87
88export const doneMessage = (title: string | undefined, opener: number = 0): string => {
89 const head = DONE_OPENERS[opener] ?? DONE_OPENERS[0]
90 return title === undefined ? `${head}!` : `${head}: ${truncate(title)}`
91}
92
93// Any index but `last`, drawn by `roll` in [0, 1); no last pick means the classic (0).
94export const pickOther = (count: number, last: number | undefined, roll: number): number => {
95 if (last === undefined || count < 2) return 0
96 const n = Math.floor(roll * (count - 1))
97 return n >= last ? n + 1 : n
98}
99
100export const nextCelebration = (last: Celebration | null, rolls: [number, number]): Celebration => ({
101 sound: pickOther(CHIMES.length, last?.sound, rolls[0]),
102 opener: pickOther(DONE_OPENERS.length, last?.opener, rolls[1]),
103})
104
105// Awaited inside the tool.call hook: once that hook returns, this plugin's own ui.toast hook
106// (toast-queue.ts) no longer sees its toasts, and a done toast it misses can't hold the line.
107// Resolves the chime to play; never throws into the tool call.
108const announce = async ($: EngineInterface, title: string | undefined): Promise<string> => {
109 try {
110 const pick = nextCelebration(await read($, lastCelebration), [Math.random(), Math.random()])
111 const isClean = await cleanStop($)
112 // The stopping point queues behind the done toast.
113 $.ui.toast(doneMessage(title, pick.opener), { timeoutMs: DONE_TOAST_MS })
114 if (isClean) $.ui.toast(STOP_TOAST)
115 await update($, lastCelebration, () => pick)
116 return CHIMES[pick.sound] ?? CHIMES[0]
117 } catch (err) {
118 $.ui.log(`done-chime: celebrating failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
119 return CHIMES[0]
120 }
121}
122
123// Detached: the result needn't wait out the sound.
124const playChime = async ($: EngineInterface, chime: string): Promise<void> => {
125 try {
126 await $.audio.play({ asset: chime })
127 } catch (err) {
128 $.ui.log(`done-chime: sound failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
129 }
130}
131
132export const registerDoneChime = (on: On, options: PluginOptions): void => {
133 const withSound = options.doneChimeSound !== false
134
135 on('tool.call', { tool: COMPLETE_TOOL }, async ($, e, next) => {
136 const ran = await next(e)
137 if (ran.deny !== undefined || ran.isError === true) return ran
138 const chime = await announce($, patchTitle(ran.text))
139 if (withSound) void playChime($, chime)
140 return ran
141 })
142
143 on('tool.call', { tool: REVIEW_TOOL }, async ($, e, next) => {
144 const ran = await next(e)
145 if (ran.deny !== undefined || ran.isError === true) return ran
146 $.ui.toast(reviewMessage(patchTitle(ran.text)), { timeoutMs: DONE_TOAST_MS })
147 if (withSound) void playChime($, REVIEW_CHIME)
148 return ran
149 })
150
151 // Remembers whether the latest test run passed, for the stopping point. Never runs tests itself.
152 on('tool.call', { tool: BASH_TOOL }, async ($, e, next) => {
153 const ran = await next(e)
154 const command = (e as { command?: unknown }).command
155 if (ran.deny === undefined && typeof command === 'string' && isTestCommand(command)) {
156 const failed = testRunFailed(ran.isError === true, outputOf(ran))
157 await update($, lastTestFailed, () => failed).catch((err: unknown) =>
158 $.ui.log(`done-chime: recording the test run failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }),
159 )
160 }
161 return ran
162 })
163}
164hooks/error-decoder.ts 133 lines1import type { EngineInterface, On } from 'claude-code'
2import { isTestCommand, outputOf, testRunFailed } from './done-chime.ts'
3import { truncate } from './patch-status.ts'
4
5// Error decoder: when a build, typecheck, test or lint run fails, one toast says what broke and the
6// likely cause in plain English. Regex first; a small Haiku call only when no pattern matches.
7// Built against Claude Code 2.1.289.
8
9const BASH_TOOL = /^Bash$/
10
11// Checks beyond tests, anywhere in the command (cd … && pnpm build | tail). Bare tsc/eslint count
12// only as a command, so `cat eslint.config.js` isn't a run.
13const CHECK_COMMAND =
14 /\b(?:(?:pnpm|npm|yarn|bun)(?:\s+-\S+)*\s+(?:run\s+)?(?:build|typecheck|type-check|lint|check|validate)|npx\s+(?:tsc|eslint|next\s+build)|next\s+build|cargo\s+(?:build|check|clippy)|go\s+(?:build|vet)|claude\s+plugin\s+validate)\b|(?:^|[;&|]\s*)(?:tsc|eslint)\b/
15
16// A pipe (| tail) hides the exit code, so the output's own failure markers count too.
17const FAILURE_MARK = /\berror TS\d+|ELIFECYCLE|Failed to compile|Build failed|✖ \d+ problems?/
18
19export const DECODE_TOAST_MS = 8000
20// The same decoded error again within this window stays quiet.
21export const REPEAT_MS = 5 * 60_000
22const MODEL_TIMEOUT_MS = 6000
23
24export const isCheckCommand = (command: string): boolean => isTestCommand(command) || CHECK_COMMAND.test(command)
25
26export const checkFailed = (isError: boolean, output: string): boolean =>
27 testRunFailed(isError, output) || FAILURE_MARK.test(output)
28
29const stripAnsi = (text: string): string => text.replace(/\x1b\[[0-9;]*m/g, '')
30
31const basename = (path: string): string => path.split('/').pop() ?? path
32
33// The first file:line the output names, as " (in wins.ts:81)".
34const where = (output: string): string => {
35 const m = /([\w./-]+\.[cm]?[jt]sx?)(?:\((\d+),\d+\)|:(\d+):\d+)/.exec(output)
36 return m ? ` (in ${basename(m[1]!)}:${m[2] ?? m[3]})` : ''
37}
38
39const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
40
41// One plain-English line for the common failures, or undefined to ask the model.
42export const decode = (raw: string): string | undefined => {
43 const output = stripAnsi(raw)
44 const at = where(output)
45
46 const name = /error TS2304: Cannot find name '([^']+)'/.exec(output)
47 if (name) return `TypeScript can't find \`${name[1]}\`, probably a missing import or a typo${at}.`
48
49 const mod = /Cannot find module '([^']+)'|Can't resolve '([^']+)'/.exec(output)
50 if (mod) return `Can't find module \`${mod[1] ?? mod[2]}\`: not installed, or the import path is off${at}.`
51
52 const prop = /error TS2339: Property '([^']+)' does not exist on type '([^']+)'/.exec(output)
53 if (prop) return `\`${prop[1]}\` isn't on \`${truncate(prop[2]!, 30)}\`: a typo, or the type needs the new field${at}.`
54
55 if (/error TS(?:2322|2345):/.test(output)) return `A value's type doesn't match what the code expects${at}.`
56
57 const tsErrors = output.match(/error TS\d+/g)
58 if (tsErrors) {
59 const first = /error TS\d+: (.+)/.exec(output)?.[1] ?? ''
60 return `${plural(tsErrors.length, 'TypeScript error')}${at}: ${truncate(first, 60)}`
61 }
62
63 const failCount = /\b([1-9]\d*)\s+(?:fail|failed|failing)\b/i.exec(output)
64 if (failCount) {
65 const n = Number(failCount[1])
66 const first = /\(fail\) (.+?)(?: \[[\d.]+m?s\])?$|^\s*[✗×] (.+)$|^FAIL\s+(\S+)/m.exec(output)
67 const named = first ? first[1] ?? first[2] ?? first[3] : undefined
68 return `${plural(n, 'test')} failing${named ? `, first: ${truncate(named.trim(), 60)}` : ''}.`
69 }
70
71 const lint = /✖ (\d+) problems?/.exec(output)
72 if (lint) return `Lint found ${plural(Number(lint[1]), 'problem')}${at}.`
73
74 const syntax = /SyntaxError: (.+)/.exec(output)
75 if (syntax) return `Syntax error${at}: ${truncate(syntax[1]!, 60)}`
76
77 return undefined
78}
79
80const askModel = async ($: EngineInterface, command: string, output: string): Promise<string | undefined> => {
81 const prompt = [
82 'A build, test or lint command failed. In one plain-English sentence under 110 characters, say what broke and the',
83 'likely cause, naming the file if the output does. No preamble, no code blocks.',
84 '',
85 `Command: ${command}`,
86 '',
87 'Output (last part):',
88 stripAnsi(output).slice(-3000),
89 ].join('\n')
90 const reply = await $.model.complete({ model: 'haiku', prompt, effort: 'low', maxTokens: 120, timeoutMs: MODEL_TIMEOUT_MS })
91 if (!reply.isAnswered) {
92 $.ui.log(`error-decoder: no model reply: ${reply.reason}`, { to: 'debug' })
93 return undefined
94 }
95 const line = reply.text.trim().split('\n')[0]?.trim()
96 return line ? truncate(line, 120) : undefined
97}
98
99// The first line that looks like the error: a repeat key for failures no pattern decodes.
100const errorLine = (output: string): string =>
101 stripAnsi(output).split('\n').find(l => /error|fail|✖|×/i.test(l))?.trim() ?? ''
102
103export const registerErrorDecoder = (on: On): void => {
104 // Module state: a reload just forgets recent errors, so one might toast twice.
105 const lastShown = new Map<string, number>()
106
107 const isRepeat = (key: string, now: number): boolean => {
108 const seen = lastShown.get(key)
109 if (seen !== undefined && now - seen < REPEAT_MS) return true
110 lastShown.set(key, now)
111 return false
112 }
113
114 // Awaited inside the hook so toast-queue.ts sees the toast and holds it behind a done toast.
115 on('tool.call', { tool: BASH_TOOL }, async ($, e, next) => {
116 const ran = await next(e)
117 const command = (e as { command?: unknown }).command
118 if (ran.deny !== undefined || typeof command !== 'string' || !isCheckCommand(command)) return ran
119 const output = outputOf(ran)
120 if (!checkFailed(ran.isError === true, output)) return ran
121 try {
122 const now = await $.clock.now()
123 const decoded = decode(output)
124 if (isRepeat(decoded ?? `${command}\n${errorLine(output)}`, now)) return ran
125 const line = decoded ?? (await askModel($, command, output))
126 if (line !== undefined) $.ui.toast(`🧩 ${line}`, { timeoutMs: DECODE_TOAST_MS })
127 } catch (err) {
128 $.ui.log(`error-decoder: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
129 }
130 return ran
131 })
132}
133hooks/focus.tsx 169 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import { KINDLING_SERVER, kindleArgs } from './park.ts'
4import { truncate } from './patch-status.ts'
5
6// #12 Focus slot: Cody names the current non-patch work; the status line shows "🎯 <focus>" when no patch is active.
7// /topic is the manual override; bare /topic asks Cody to name the work.
8// Built against Claude Code 2.1.289.
9
10const MAX_FOCUS = 40
11// What bare /topic hands Cody: name the work and set it, nothing else.
12export const NAME_IT_PROMPT =
13 'Name what we\'re working on right now: call set_focus with a short plain label (40 chars max). Just set it; no need to do anything else.'
14// Matched by pattern, not literal: a literal tool name sends tsc through every known tool's types (out of memory).
15const SET_TOOL = /^mcp__codynd__set_focus$/
16const CLEAR_TOOL = /^mcp__codynd__clear_focus$/
17
18// The same state patch-status.ts and patches-pane.tsx read (atoms are declared per file).
19// Writing it redraws the status line (patch-status.ts's state.set hook).
20const focus = atom({ plugin: 'codynd', key: 'focus' } as const, null)
21const parkOffer = atom({ plugin: 'codynd', key: 'parkOffer' } as const, null)
22
23// Short and plain: one line, trimmed, capped at 40 characters.
24export const cleanFocus = (text: string): string | null => {
25 const oneLine = text.replace(/\s+/g, ' ').trim()
26 return oneLine === '' ? null : truncate(oneLine, MAX_FOCUS)
27}
28
29// Only a replaced focus is worth rescuing (a clear means the work wrapped up), and each label is
30// offered once a session, so switching back and forth never nags.
31export const offerFor = (old: string | null, label: string | null, offered: ReadonlySet<string>): string | null =>
32 old === null || label === null || old === label || offered.has(old) ? null : old
33
34const setFocus = async ($: EngineInterface, label: string | null): Promise<void> => {
35 await update($, focus, () => label)
36}
37
38// A focus change from Cody or /topic: set it, and quietly offer to park the one it replaced.
39const changeFocus = async ($: EngineInterface, label: string | null, offered: Set<string>): Promise<void> => {
40 const old = await read($, focus)
41 await setFocus($, label)
42 const offer = offerFor(old, label, offered)
43 if (offer === null) return
44 offered.add(offer)
45 await update($, parkOffer, () => offer)
46}
47
48const askCodyToNameIt = async ($: EngineInterface): Promise<void> => {
49 try {
50 await $.prompt.submit({ text: NAME_IT_PROMPT })
51 } catch (err) {
52 $.ui.log(`focus: asking Cody to name the work failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
53 $.ui.toast("Couldn't ask Cody; try /topic <label>.")
54 }
55}
56
57const dismissOffer = async ($: EngineInterface): Promise<void> => {
58 await update($, parkOffer, () => null)
59}
60
61// Parks the way /park does. A failed save keeps the offer up, so the thread isn't lost and Park can be pressed again.
62const parkOld = async ($: EngineInterface, server: string, old: string): Promise<void> => {
63 try {
64 const result = await $.mcp.call(server, 'kindle', kindleArgs(old, await $.session.cwd()))
65 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
66 $.ui.toast(`🅿️ Parked: ${truncate(old, 40)}`)
67 await dismissOffer($)
68 } catch (err) {
69 $.ui.log(`focus: parking the old focus failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
70 $.ui.toast("Couldn't park that one; try again or dismiss.")
71 }
72}
73
74export const registerFocus = (on: On, options: PluginOptions): void => {
75 const server = typeof options.kindlingServer === 'string' ? options.kindlingServer : KINDLING_SERVER
76 // Module state: a reload just forgets which labels were offered, which at worst offers one again.
77 const offered = new Set<string>()
78
79 on('session.start', { isInteractive: true }, async ($, e, next) => {
80 const result = await next(e)
81 offered.clear()
82 await $.tool.register({
83 name: 'set_focus',
84 description:
85 "Set the status-line focus to a short plain label (40 chars max) naming the non-patch work you and Nae are doing now. Call it when Nae brings a new topic that isn't an in-progress ChaosPatch patch, or when the topic changes. Don't call it for patch work: an in-progress patch already shows and always wins.",
86 inputSchema: {
87 type: 'object',
88 properties: { text: { type: 'string', description: 'The label, e.g. "Debugging Tangle identity"' } },
89 required: ['text'],
90 },
91 })
92 await $.tool.register({
93 name: 'clear_focus',
94 description: 'Clear the status-line focus when the non-patch work wraps up.',
95 })
96 // Not /focus: that's a built-in, and a session start refuses the name. A refusal here mustn't
97 // undo the tools above, so it's logged rather than thrown.
98 await $.command
99 .register({
100 name: 'topic',
101 description: 'Set the status-line focus: /topic <label> by hand, /topic alone to have Cody name it, /topic clear to clear',
102 argumentHint: '[label | clear]',
103 immediate: true,
104 })
105 .catch((err: unknown) => $.ui.log(`focus: /topic not registered: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }))
106 return result
107 })
108
109 on('tool.call', { tool: SET_TOOL }, async ($, e) => {
110 const text = (e as { text?: unknown }).text
111 const label = cleanFocus(typeof text === 'string' ? text : '')
112 if (label === null) return { result: 'Focus not set: the label was empty.', isError: true }
113 await changeFocus($, label, offered)
114 return { result: `Focus set: ${label}` }
115 })
116
117 on('tool.call', { tool: CLEAR_TOOL }, async $ => {
118 await setFocus($, null)
119 return { result: 'Focus cleared.' }
120 })
121
122 on('command.run', { command: 'topic' }, async ($, e) => {
123 const typed = e.args.trim()
124 if (typed === '') {
125 // A command.run hook can't submit a prompt (it would wait on the turn it holds), so hand it
126 // to a timer that fires once this command has returned.
127 $.clock.after(0, () => void askCodyToNameIt($))
128 return { text: 'Asking Cody to name it…' }
129 }
130 const label = typed.toLowerCase() === 'clear' ? null : cleanFocus(typed)
131 await changeFocus($, label, offered)
132 return { text: label === null ? 'Focus cleared.' : `Focus: ${label}` }
133 })
134
135 // /clear ends the session's context, so the focus goes with it (the line redraws on the write).
136 on('session.end', async ($, e, next) => {
137 await setFocus($, null)
138 await dismissOffer($)
139 return next(e)
140 })
141
142 // One chance, never blocking: the offer goes with Nae's next prompt, like the re-entry card.
143 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
144 await dismissOffer($)
145 return next(e)
146 })
147
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 const old = await read($, parkOffer)
150 if (old === null || e.props.hasSurvey) return next(e)
151 const { Box, Button, Text } = $.ui.resolve(e)
152 return (
153 <Box borderStyle="round" borderDimColor flexDirection="row" gap={1} paddingX={1}>
154 <Box flexGrow={1} flexShrink={1}>
155 <Text wrap="truncate-end">
156 🅿️ <Text dimColor>Park </Text>"{old}"<Text dimColor> in Kindling?</Text>
157 </Text>
158 </Box>
159 <Button key="park" onPress={() => parkOld($, server, old)}>
160 Park
161 </Button>
162 <Button key="dismiss" role="dismiss" onPress={() => dismissOffer($)}>
163 Dismiss
164 </Button>
165 </Box>
166 )
167 })
168}
169hooks/instructions.ts 84 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4// Instruction hooks: reminders aimed at Cody, so CLAUDE.md rules don't fade in long sessions.
5// Everything here rides as model-only context; Nae never sees a toast or a warning.
6// Built against Claude Code 2.1.289.
7
8const EDIT_TOOL = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/
9// Review ends the active patch as completing does: both close Cody's part and want a note.
10const CLOSE_TOOL = /__cp_(complete_patch|request_review)$/
11
12// Nae's rule list, edited without code. Read once per conversation (again after /clear or compaction).
13export const RULES_FILE = 'rules.md'
14export const RULES_BLOCK = 'codyndRules'
15
16export const PATCH_OR_FOCUS =
17 'CodyND reminder: no patch is in progress here and no focus is set. Before more code changes, start the patch (cp_start_patch) or name the work (set_focus).'
18export const COMPLETION_NOTE =
19 'CodyND reminder: that patch closed without a completion note. Add one with cp_add_note: what shipped, and the version.'
20export const REVIEW_NOTE =
21 'CodyND reminder: that patch went to review without a note. Add one with cp_add_note: what shipped, the version, and exactly what Nae should check.'
22
23// The same state patch-status.ts and focus.tsx write (atoms are declared per file).
24const activePatches = atom({ plugin: 'codynd', key: 'activePatches' } as const, [])
25const focus = atom({ plugin: 'codynd', key: 'focus' } as const, null)
26
27export const hasNote = (note: unknown): boolean => typeof note === 'string' && note.trim() !== ''
28
29const loadRules = async ($: EngineInterface): Promise<string | undefined> => {
30 try {
31 const text = (await $.fs.read(`${$.plugin.root}/${RULES_FILE}`)).trim()
32 return text === '' ? undefined : text
33 } catch (err) {
34 $.ui.log(`instructions: reading ${RULES_FILE} failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
35 return undefined
36 }
37}
38
39// Covered: a patch in progress for this repo, or a named focus.
40const isCovered = async ($: EngineInterface): Promise<boolean> => {
41 try {
42 return (await read($, activePatches)).length > 0 || (await read($, focus)) !== null
43 } catch (err) {
44 $.ui.log(`instructions: reading the patch and focus failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
45 return true
46 }
47}
48
49export const registerInstructions = (on: On): void => {
50 // Once per topic: cleared with each new conversation (/clear, compaction), when the focus
51 // changes or clears, or when a patch completes or goes to review.
52 // Module state: a reload just forgets, which at worst reminds once more.
53 let reminded = false
54
55 on('prompt.context', async ($, e, next) => {
56 reminded = false
57 const result = await next(e)
58 const rules = await loadRules($)
59 if (rules === undefined) return result
60 return { ...result, blocks: [...result.blocks.filter(b => b.name !== RULES_BLOCK), { name: RULES_BLOCK, text: rules }] }
61 })
62
63 on('state.set', { plugin: 'codynd', key: 'focus' }, async ($, e, next) => {
64 reminded = false
65 return next(e)
66 })
67
68 on('tool.call', { tool: EDIT_TOOL }, async ($, e, next) => {
69 const ran = await next(e)
70 if (ran.deny !== undefined || reminded || (await isCovered($))) return ran
71 reminded = true
72 return { ...ran, context: [...(ran.context ?? []), PATCH_OR_FOCUS] }
73 })
74
75 on('tool.call', { tool: CLOSE_TOOL }, async ($, e, next) => {
76 const ran = await next(e)
77 if (ran.deny !== undefined || ran.isError === true) return ran
78 reminded = false
79 if (hasNote((e as { note?: unknown }).note)) return ran
80 const reminder = e.tool.endsWith('cp_request_review') ? REVIEW_NOTE : COMPLETION_NOTE
81 return { ...ran, context: [...(ran.context ?? []), reminder] }
82 })
83}
84hooks/next-event.ts 122 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { NextEvent } from '../types'
4import { STARTUP_RETRY_MS, truncate } from './patch-status.ts'
5
6// Next-event countdown: the next ControlledChaos event in the status line, so time stays visible
7// during hyperfocus. Visibility only: one heads-up at 15 minutes (a toast and a cabin "bing-bong"),
8// never a repeat.
9// Built against Claude Code 2.1.289.
10
11const DEFAULT_SERVER = 'claude.ai ControlledChaos'
12const REFRESH_MS = 5 * 60_000
13const TICK_MS = 60_000
14// ControlledChaos is often still connecting at session start (same race as patch-status.ts).
15const STARTUP_RETRIES = 6
16export const WINDOW_MS = 3 * 60 * 60_000
17export const SOON_MS = 15 * 60_000
18// An airline cabin chime, to go with "Start landing the plane"; unlike the done chimes and the yoo-hoo.
19export const LANDING_CHIME = 'sounds/landing.wav'
20
21const nextEvent = atom({ plugin: 'codynd', key: 'nextEvent' } as const, null)
22
23// cc_list_calendar prints times in Nae's timezone with its abbreviation; these are the US zones.
24const ZONE_OFFSET_H: Record<string, number> = {
25 UTC: 0, GMT: 0, EST: -5, EDT: -4, CST: -6, CDT: -5, MST: -7, MDT: -6, PST: -8, PDT: -7, AKST: -9, AKDT: -8, HST: -10,
26}
27const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
28
29// "Mon, Oct 5, 2026, 10:20 AM EDT" → ms. Undefined for anything else, so an odd line is skipped, not misplaced.
30export const parseCalendarTime = (text: string): number | undefined => {
31 const m = /^\w{3}, (\w{3}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}) (AM|PM) ([A-Z]{2,4})$/.exec(text.trim())
32 if (m === null) return undefined
33 const [, mon, day, year, hour, minute, half, zone] = m as unknown as [string, string, string, string, string, string, string, string]
34 const month = MONTHS.indexOf(mon)
35 const offset = ZONE_OFFSET_H[zone]
36 if (month === -1 || offset === undefined) return undefined
37 const h = (Number(hour) % 12) + (half === 'PM' ? 12 : 0)
38 return Date.UTC(Number(year), month, Number(day), h, Number(minute)) - offset * 3_600_000
39}
40
41// The soonest timed event still ahead. All-day and tentative events don't count: no "in 40m" for
42// an all-day one, and a tentative one's time is free. Planned work blocks aren't requested.
43export const parseNextEvent = (markdown: string, now: number): NextEvent | null => {
44 const events = markdown.split('## Planned Work')[0] ?? ''
45 let soonest: NextEvent | null = null
46 for (const block of events.split(/^### /m).slice(1)) {
47 if (/^All day event$/m.test(block) || /^Commitment: TENTATIVE/m.test(block)) continue
48 const title = /^\d+\. \*\*(.+?)\*\*/.exec(block)?.[1]
49 const id = /^ID: `([^`]+)`/m.exec(block)?.[1]
50 const start = /^Start: (.+)$/m.exec(block)?.[1]
51 const startsAt = start === undefined ? undefined : parseCalendarTime(start)
52 if (title === undefined || id === undefined || startsAt === undefined || startsAt <= now) continue
53 if (soonest === null || startsAt < soonest.startsAt) {
54 soonest = { id, title, startsAt, category: /^Category: (\w+)/m.exec(block)?.[1] ?? null }
55 }
56 }
57 return soonest
58}
59
60export const landingToast = (event: NextEvent, minutes: number): string =>
61 `🛬 ${truncate(event.title, 40)} in ${minutes}m. Start landing the plane.`
62
63// Unreachable or odd output: no segment, a debug log line, nothing on screen, and another try soon
64// while `retries` remain (only the session-start load has any). Writing nextEvent redraws the line
65// (patch-status.ts's state.set hook).
66const refresh = async ($: EngineInterface, server: string, retries = 0): Promise<void> => {
67 try {
68 const now = await $.clock.now()
69 const result = await $.mcp.call(server, 'cc_list_calendar', {
70 start_date: new Date(now).toISOString(),
71 end_date: new Date(now + WINDOW_MS).toISOString(),
72 include_planned: false,
73 })
74 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
75 const event = parseNextEvent(result.content.map(b => b.text ?? '').join(''), now)
76 await update($, nextEvent, () => event)
77 } catch (err) {
78 $.ui.log(`next-event: refresh failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
79 await update($, nextEvent, () => null).catch(() => undefined)
80 if (retries > 0) $.clock.after(STARTUP_RETRY_MS, () => void refresh($, server, retries - 1))
81 }
82}
83
84// Detached: the heads-up never waits on the sound.
85const playLanding = async ($: EngineInterface): Promise<void> => {
86 try {
87 await $.audio.play({ asset: LANDING_CHIME })
88 } catch (err) {
89 $.ui.log(`next-event: sound failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
90 }
91}
92
93// One heads-up per event, the first tick it's within 15 minutes.
94const checkSoon = async ($: EngineInterface, warned: Set<string>, withSound: boolean): Promise<void> => {
95 try {
96 const event = await read($, nextEvent)
97 if (event === null || warned.has(event.id)) return
98 const left = event.startsAt - (await $.clock.now())
99 if (left <= 0 || left > SOON_MS) return
100 warned.add(event.id)
101 $.ui.toast(landingToast(event, Math.ceil(left / 60_000)), { timeoutMs: 10_000 })
102 if (withSound) void playLanding($)
103 } catch (err) {
104 $.ui.log(`next-event: heads-up failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
105 }
106}
107
108export const registerNextEvent = (on: On, options: PluginOptions): void => {
109 const server = typeof options.controlledChaosServer === 'string' ? options.controlledChaosServer : DEFAULT_SERVER
110 const warned = new Set<string>()
111 const withSound = options.landingChimeSound !== false
112
113 on('session.start', { isInteractive: true }, async ($, e, next) => {
114 const result = await next(e)
115 void refresh($, server, STARTUP_RETRIES)
116 $.clock.every(REFRESH_MS, () => void refresh($, server))
117 // Minutes tick locally (patch-status.ts redraws the line each minute); this only watches for 15m.
118 $.clock.every(TICK_MS, () => void checkSoon($, warned, withSound))
119 return result
120 })
121}
122hooks/park.ts 53 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2import { truncate } from './patch-status.ts'
3
4// #6 /park: send a side-thought to Kindling without leaving the session.
5// Built against Claude Code 2.1.289.
6
7export const KINDLING_SERVER = 'claude.ai Kindling'
8const TITLE_AFTER = 120
9
10// Kindling lowercases tags; the folder name keeps parked thoughts findable per repo.
11export const parkTags = (cwd: string): string[] => {
12 const folder = cwd.split('/').filter(Boolean).pop()
13 return folder === undefined ? ['parked'] : ['parked', `project:${folder.toLowerCase()}`]
14}
15
16// Long thoughts get a short title so they stay scannable in Kindling's list.
17export const kindleArgs = (text: string, cwd: string): Record<string, unknown> => ({
18 content: text,
19 tags: parkTags(cwd),
20 ...(text.length > TITLE_AFTER ? { title: truncate(text) } : {}),
21})
22
23// Saved: a toast and no transcript line. Not saved: the thought is echoed back so it isn't lost.
24const park = async ($: EngineInterface, server: string, text: string): Promise<{ text?: string }> => {
25 try {
26 const result = await $.mcp.call(server, 'kindle', kindleArgs(text, await $.session.cwd()))
27 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
28 $.ui.toast(`🅿️ Parked: ${truncate(text, 40)}`)
29 return {}
30 } catch (err) {
31 $.ui.log(`park: kindle failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
32 $.ui.toast("Couldn't park that one; it's in the transcript.")
33 return { text: `Not saved to Kindling. Your thought: ${text}` }
34 }
35}
36
37export const registerPark = (on: On, options: PluginOptions): void => {
38 const server = typeof options.kindlingServer === 'string' ? options.kindlingServer : KINDLING_SERVER
39
40 on('session.start', { isInteractive: true }, async ($, e, next) => {
41 const result = await next(e)
42 // Immediate: runs the moment it's typed, even mid-turn, so a parked thought never waits on Cody.
43 await $.command.register({ name: 'park', description: 'Park a side-thought in Kindling: /park <thought>', immediate: true })
44 return result
45 })
46
47 on('command.run', { command: 'park' }, async ($, e) => {
48 const text = e.args.trim()
49 if (text === '') return { text: 'Usage: /park <thought>' }
50 return park($, server, text)
51 })
52}
53hooks/patch-status.ts 213 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { NextEvent, Patch, WorkStretch } from '../types'
4import { BREAK_MS } from './session-clock.ts'
5
6// #1 Patch status line: shows the in-progress ChaosPatch patch for this repo.
7// #7 Patch timer: with how long it has been in progress.
8// The one place the line is drawn: other modules write state, and a state.set hook here redraws.
9// That hook misses this file's own writes and writes made in a /patches button press, so refresh()
10// here and loadBoard() in patches-pane.tsx draw for themselves.
11// Built against Claude Code 2.1.289.
12
13export type { Patch }
14
15// In $.state, not a module variable: /patches writes it after a press, since a plugin's
16// own $.tool.call skips its own tool.call hooks (so the refresh hook below never sees it).
17const activePatches = atom({ plugin: 'codynd', key: 'activePatches' } as const, [])
18// #12: shown when no patch is in progress (set by focus.ts).
19const focus = atom({ plugin: 'codynd', key: 'focus' } as const, null)
20// next-event.ts caches it; the line ticks its minutes each redraw.
21const nextEvent = atom({ plugin: 'codynd', key: 'nextEvent' } as const, null)
22const workStretch = atom({ plugin: 'codynd', key: 'workStretch' } as const, null)
23// Changes to these redraw the line.
24const LINE_KEYS: readonly string[] = ['activePatches', 'focus', 'nextEvent', 'workStretch']
25
26export const DEFAULT_SERVER = 'claude.ai ChaosPatch'
27const REFRESH_MS = 5 * 60_000
28// At session start ChaosPatch is often still connecting, so the first load fails. Without a quick
29// retry the line, and the patch-or-focus reminder that reads it, sat empty until the 5-minute refresh.
30export const STARTUP_RETRY_MS = 20_000
31const STARTUP_RETRIES = 6
32// Elapsed time redraws from the cached patches; no ChaosPatch call.
33const TICK_MS = 60_000
34const MAX_TITLE = 60
35// Any ChaosPatch call that can change which patch is in progress.
36const PATCH_WRITE_TOOL = /__cp_(start_patch|complete_patch|request_review|update_patch|reopen_patch|delete_patch|batch_update)$/
37
38// "chicken-scratch", "Chicken Scratch" and "ChickenScratch" all become "chickenscratch".
39export const normalize = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '')
40
41// Anything but JSON is an error the host put in place of the result (an oversized one, say); say what it was.
42export const parsePatches = (text: string): Patch[] => {
43 let parsed: unknown
44 try {
45 parsed = JSON.parse(text)
46 } catch {
47 throw new Error(truncate(text.trim() || 'an empty reply', 160))
48 }
49 return Array.isArray(parsed) ? (parsed as Patch[]) : []
50}
51
52// Claude Code swaps an MCP result over ~25k tokens for an "Error: … exceeds maximum allowed tokens"
53// text. Every open patch across projects (80+ at ~280 tokens each) crossed it, so long lists come in
54// pages: 50 is about 14k tokens.
55export const PAGE_SIZE = 50
56
57// Normalized folder name → normalized project slug or name, for repos whose folder doesn't match.
58export type Aliases = Readonly<Record<string, string>>
59
60// The projectAliases setting: "ADHD-AgenticDevHumanDesigns=adhdesigns, other-folder=other-slug".
61export const parseAliases = (raw: unknown): Aliases => {
62 if (typeof raw !== 'string') return {}
63 const pairs = raw.split(',').flatMap(pair => {
64 const [folder = '', project = ''] = pair.split('=').map(normalize)
65 return folder !== '' && project !== '' ? [[folder, project] as const] : []
66 })
67 return Object.fromEntries(pairs)
68}
69
70// A repo matches a project when its folder name (or that folder's alias) equals the project's slug or name.
71export const matchesRepo = (slug: string, name: string, cwd: string, aliases: Aliases = {}): boolean => {
72 const folder = normalize(cwd.split('/').filter(Boolean).pop() ?? '')
73 if (folder === '') return false
74 const key = aliases[folder] ?? folder
75 return normalize(slug) === key || normalize(name) === key
76}
77
78export const patchesForCwd = (patches: Patch[], cwd: string, aliases: Aliases = {}): Patch[] =>
79 patches
80 .filter(p => matchesRepo(p.project_slug, p.project_name, cwd, aliases))
81 .sort((a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? ''))
82
83export const truncate = (s: string, max = MAX_TITLE): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
84
85// Informational, never alarming: past a day it's whole days ("3d"), not "74h 12m".
86export const formatElapsed = (ms: number): string => {
87 const minutes = Math.floor(ms / 60_000)
88 if (minutes < 1) return '<1m'
89 if (minutes < 60) return `${minutes}m`
90 const hours = Math.floor(minutes / 60)
91 if (hours < 24) return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
92 return `${Math.floor(hours / 24)}d`
93}
94
95export const formatStatus = (patches: Patch[], now?: number): string | undefined => {
96 const [newest, ...rest] = patches
97 if (newest === undefined) return undefined
98 const startedAt = newest.started_at === null ? NaN : Date.parse(newest.started_at)
99 const elapsed = now === undefined || Number.isNaN(startedAt) ? '' : ` · ${formatElapsed(Math.max(0, now - startedAt))}`
100 const more = rest.length > 0 ? ` (+${rest.length})` : ''
101 return `🩹 ${truncate(newest.title)}${elapsed}${more}`
102}
103
104const EVENT_EMOJI: Record<string, string> = { school: '📚', work: '💼', personal: '🏠', errands: '🛒', health: '🩺' }
105const EVENT_WINDOW_MS = 3 * 60 * 60_000
106// The line is plain text, so "amber" is the emoji: 🟠 from 15 minutes out.
107const EVENT_SOON_MS = 15 * 60_000
108
109// "📚 Latin in 40m"; nothing once it has started or while it's more than 3h off.
110export const eventSegment = (event: NextEvent | null, now: number): string | undefined => {
111 if (event === null) return undefined
112 const left = event.startsAt - now
113 if (left <= 0 || left > EVENT_WINDOW_MS) return undefined
114 const emoji = left <= EVENT_SOON_MS ? '🟠' : (EVENT_EMOJI[event.category ?? ''] ?? '📅')
115 return `${emoji} ${truncate(event.title, 30)} in ${formatElapsed(left + 59_999)}`
116}
117
118// "⏱ 45m in": the body-check clock's stretch. Hidden on a break, so it never counts up while Nae's away.
119export const stretchSegment = (stretch: WorkStretch | null, now: number): string | undefined =>
120 stretch === null || now - stretch.lastActive >= BREAK_MS ? undefined : `⏱ ${formatElapsed(Math.max(0, now - stretch.start))} in`
121
122export type LineParts = {
123 patches: Patch[]
124 focus: string | null
125 now?: number
126 event?: NextEvent | null
127 stretch?: WorkStretch | null
128}
129
130// The whole line: an in-progress patch wins, then the focus, then the work stretch; the next event
131// rides alongside whichever shows. The event leads: the status line is one line ("\n" is flattened),
132// and a narrow terminal cuts the end, so a long patch title gets trimmed rather than the event.
133export const statusLine = ({ patches, focus: label, now, event = null, stretch = null }: LineParts): string | undefined => {
134 const work =
135 formatStatus(patches, now) ?? (label !== null ? `🎯 ${label}` : now === undefined ? undefined : stretchSegment(stretch, now))
136 const segments = [now === undefined ? undefined : eventSegment(event, now), work].filter(s => s !== undefined)
137 return segments.length === 0 ? undefined : segments.join(' │ ')
138}
139
140// A write being drawn: its own value wins over a read, since every read in one dispatch sees the
141// moment that dispatch began (a refresh started at session start would read the old value back).
142type Written = { key: string; value: unknown }
143
144const valueOr = <T,>(written: Written | undefined, key: string, readValue: () => Promise<T>): Promise<T> =>
145 written?.key === key ? Promise.resolve(written.value as T) : readValue()
146
147// Runs from timers: it catches its own errors, so a reload mid-draw leaves no unhandled rejection.
148const draw = async ($: EngineInterface, written?: Written): Promise<void> => {
149 try {
150 $.ui.status(
151 statusLine({
152 patches: await valueOr(written, 'activePatches', () => read($, activePatches)),
153 focus: await valueOr(written, 'focus', () => read($, focus)),
154 now: await $.clock.now(),
155 event: await valueOr(written, 'nextEvent', () => read($, nextEvent)),
156 stretch: await valueOr(written, 'workStretch', () => read($, workStretch)),
157 }),
158 )
159 } catch (err) {
160 $.ui.log(`patch-status: draw failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
161 }
162}
163
164// Unreachable server or bad response: clear the line, note it in the debug log only, and try
165// again soon while `retries` remain (only the session-start load has any).
166const refresh = async ($: EngineInterface, server: string, aliases: Aliases, retries = 0): Promise<void> => {
167 try {
168 const [cwd, result] = await Promise.all([
169 $.session.cwd(),
170 $.mcp.call(server, 'cp_list_all_patches', { status: 'in_progress' }),
171 ])
172 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
173 const text = result.content.map(b => b.text ?? '').join('')
174 const patches = patchesForCwd(parsePatches(text), cwd, aliases)
175 await update($, activePatches, () => patches)
176 await draw($, { key: 'activePatches', value: patches })
177 } catch (err) {
178 $.ui.log(`patch-status: refresh failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
179 await update($, activePatches, () => []).catch((e: unknown) =>
180 $.ui.log(`patch-status: clearing failed: ${e instanceof Error ? e.message : String(e)}`, { to: 'debug' }),
181 )
182 await draw($, { key: 'activePatches', value: [] })
183 if (retries > 0) $.clock.after(STARTUP_RETRY_MS, () => void refresh($, server, aliases, retries - 1))
184 }
185}
186
187export const registerPatchStatus = (on: On, options: PluginOptions): void => {
188 const server = typeof options.chaospatchServer === 'string' ? options.chaospatchServer : DEFAULT_SERVER
189 const aliases = parseAliases(options.projectAliases)
190
191 on('session.start', async ($, e, next) => {
192 const result = await next(e)
193 void refresh($, server, aliases, STARTUP_RETRIES)
194 $.clock.every(REFRESH_MS, () => void refresh($, server, aliases))
195 $.clock.every(TICK_MS, () => void draw($))
196 return result
197 })
198
199 on('state.set', async ($, e, next) => {
200 const result = await next(e)
201 if (e.plugin === 'codynd' && LINE_KEYS.includes(e.key)) await draw($, { key: e.key, value: e.value })
202 return result
203 })
204
205 // Awaited, so the patch list is current before Cody sees the result: a background refresh lost
206 // the race to Cody's next edit, and the patch-or-focus reminder read the old, empty list.
207 on('tool.call', { tool: PATCH_WRITE_TOOL }, async ($, e, next) => {
208 const ran = await next(e)
209 await refresh($, server, aliases)
210 return ran
211 })
212}
213hooks/patches-pane.tsx 333 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { Board, BoardRow, FriedRow } from '../types'
4import { CHIMES, DONE_TOAST_MS, STOP_TOAST, doneMessage, isCleanStop, nextCelebration } from './done-chime.ts'
5import { DEFAULT_SERVER, PAGE_SIZE, parseAliases, parsePatches, patchesForCwd, statusLine, type Aliases, type Patch } from './patch-status.ts'
6import { FADE_SCRIPT, SOUNDTRACK_KEY, START_SCRIPT, playlistUri } from './soundtrack.ts'
7
8// #11 /patches: this repo's ChaosPatch board in a pane, driven by buttons, no model turn.
9// /fried: the same pane idea for tired evenings, low-energy open patches across projects.
10// Built against Claude Code 2.1.289.
11
12const PANE = 'codynd-patches'
13const FRIED_PANE = 'codynd-fried'
14export const FRIED_EMPTY = "Nothing tiny queued. Maybe that's your sign to rest 💜"
15const MAX_OPEN = 9
16// One quick retry: a ChaosPatch deploy or reconnect drops a single call that the next one gets through.
17export const LOAD_RETRY_MS = 2_000
18const board = atom({ plugin: 'codynd', key: 'board' } as const, null)
19const friedBoard = atom({ plugin: 'codynd', key: 'friedBoard' } as const, null)
20// The same state as patch-status.ts's activePatches (the validator wants atoms declared per file).
21const activePatches = atom({ plugin: 'codynd', key: 'activePatches' } as const, [])
22const focus = atom({ plugin: 'codynd', key: 'focus' } as const, null)
23const lastCelebration = atom({ plugin: 'codynd', key: 'lastCelebration' } as const, null)
24const nextEvent = atom({ plugin: 'codynd', key: 'nextEvent' } as const, null)
25const workStretch = atom({ plugin: 'codynd', key: 'workStretch' } as const, null)
26const lastTestFailed = atom({ plugin: 'codynd', key: 'lastTestFailed' } as const, null)
27const soundtrackStarted = atom({ plugin: 'codynd', key: 'soundtrackStarted' } as const, false)
28
29// The chime, and the soundtrack's playlist and fade (soundtrack.ts), from the settings.
30type Sound = { chime: boolean; playlist: string; fadeOnDone: boolean }
31
32// "claude.ai ChaosPatch" is listed to the model as mcp__claude_ai_ChaosPatch__<tool>.
33export const mcpToolName = (server: string, tool: string): string =>
34 `mcp__${server.replace(/[^A-Za-z0-9_-]/g, '_')}__${tool}`
35
36export const toRows = (patches: Patch[]): BoardRow[] =>
37 patches.flatMap(p => {
38 const id = (p as Patch & { id?: unknown }).id
39 return typeof id === 'string' ? [{ id, title: p.title }] : []
40 })
41
42// Low-energy rows from every project; only this repo's can be handed to Cody here.
43export const toFriedRows = (patches: Patch[], cwd: string, aliases: Aliases = {}): FriedRow[] =>
44 patches.flatMap(p =>
45 toRows([p]).map(row => ({ ...row, project: p.project_name, isHere: patchesForCwd([p], cwd, aliases).length > 0 })),
46 )
47
48export const handoffPrompt = (row: BoardRow): string => `Start ChaosPatch patch "${row.title}" (id ${row.id}).`
49
50const listForRepo = async (
51 $: EngineInterface,
52 server: string,
53 args: Record<string, unknown>,
54 cwd: string,
55 aliases: Aliases,
56): Promise<Patch[]> => {
57 const patches: Patch[] = []
58 for (let offset = 0; ; offset += PAGE_SIZE) {
59 const result = await $.mcp.call(server, 'cp_list_all_patches', { ...args, limit: PAGE_SIZE, offset })
60 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
61 const page = parsePatches(result.content.map(b => b.text ?? '').join(''))
62 patches.push(...page)
63 if (page.length < PAGE_SIZE) return patchesForCwd(patches, cwd, aliases)
64 }
65}
66
67// The pane shows why, so a failure can be traced without a debug log.
68export const unreachable = (err: unknown): string =>
69 `Couldn't reach ChaosPatch: ${(err instanceof Error ? err.message : String(err)).trim() || 'no reason given'}`
70
71const loadBoard = async ($: EngineInterface, server: string, aliases: Aliases, retries = 0): Promise<void> => {
72 try {
73 const cwd = await $.session.cwd()
74 const [inProgress, review, open] = await Promise.all([
75 listForRepo($, server, { status: 'in_progress' }, cwd, aliases),
76 listForRepo($, server, { status: 'review' }, cwd, aliases),
77 listForRepo($, server, { status: 'open', sort_by: 'priority' }, cwd, aliases),
78 ])
79 await update($, board, () => ({ inProgress: toRows(inProgress), review: toRows(review), open: toRows(open).slice(0, MAX_OPEN) }))
80 // Keep the status line in step: it shares this list. patch-status.ts's redraw hook misses writes
81 // made in a button press, so draw here with the list just written.
82 await update($, activePatches, () => inProgress)
83 $.ui.status(
84 statusLine({
85 patches: inProgress,
86 focus: await read($, focus),
87 now: await $.clock.now(),
88 event: await read($, nextEvent),
89 stretch: await read($, workStretch),
90 }),
91 )
92 } catch (err) {
93 $.ui.log(`patches: load failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
94 if (retries > 0) {
95 await $.clock.sleep(LOAD_RETRY_MS)
96 return loadBoard($, server, aliases, retries - 1)
97 }
98 await update($, board, () => ({ inProgress: [], open: [], review: [], error: unreachable(err) })).catch((e: unknown) =>
99 $.ui.log(`patches: showing the error failed: ${e instanceof Error ? e.message : String(e)}`, { to: 'debug' }),
100 )
101 }
102}
103
104// The server filters by tag and sorts by priority; nothing else to do here.
105const loadFried = async ($: EngineInterface, server: string, aliases: Aliases): Promise<void> => {
106 try {
107 const cwd = await $.session.cwd()
108 const result = await $.mcp.call(server, 'cp_list_all_patches', { status: 'open', tags: ['energy:low'], sort_by: 'priority' })
109 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
110 const patches = parsePatches(result.content.map(b => b.text ?? '').join(''))
111 await update($, friedBoard, () => ({ rows: toFriedRows(patches, cwd, aliases).slice(0, MAX_OPEN) }))
112 } catch (err) {
113 $.ui.log(`patches: /fried load failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
114 await update($, friedBoard, () => ({ rows: [], error: "Couldn't reach ChaosPatch." })).catch((e: unknown) =>
115 $.ui.log(`patches: showing the /fried error failed: ${e instanceof Error ? e.message : String(e)}`, { to: 'debug' }),
116 )
117 }
118}
119
120// The done chime, played here: a plugin's own $.tool.call skips its own tool.call hooks,
121// so done-chime.ts never sees a Done pressed in this pane.
122const celebrate = async ($: EngineInterface, title: string, withSound: boolean): Promise<void> => {
123 let chime: string = CHIMES[0]
124 try {
125 const pick = nextCelebration(await read($, lastCelebration), [Math.random(), Math.random()])
126 chime = CHIMES[pick.sound] ?? CHIMES[0]
127 const git = await $.process.run(['git', 'status', '--porcelain']).catch(() => undefined)
128 const isClean = git !== undefined && git.exitCode === 0 && isCleanStop(git.stdout, await read($, lastTestFailed))
129 $.ui.toast(doneMessage(title, pick.opener), { timeoutMs: DONE_TOAST_MS })
130 if (isClean) $.ui.toast(STOP_TOAST)
131 await update($, lastCelebration, () => pick)
132 } catch (err) {
133 $.ui.log(`patches: celebrating failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
134 }
135 if (withSound) void playChime($, chime)
136}
137
138const playChime = async ($: EngineInterface, chime: string): Promise<void> => {
139 try {
140 await $.audio.play({ asset: chime })
141 } catch (err) {
142 $.ui.log(`patches: sound failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
143 }
144}
145
146// The soundtrack, run here too: the pane's own $.tool.call skips soundtrack.ts's hooks.
147// osascript's answer; undefined when it couldn't run at all.
148const spotify = async ($: EngineInterface, script: string, args: string[]): Promise<string | undefined> => {
149 try {
150 const ran = await $.process.run(['osascript', '-e', script, ...args])
151 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `exit ${ran.exitCode}`)
152 return ran.stdout.trim()
153 } catch (err) {
154 $.ui.log(`patches: spotify unreachable: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
155 return undefined
156 }
157}
158
159const soundtrackMusic = async ($: EngineInterface, tool: string, sound: Sound): Promise<void> => {
160 try {
161 if ((await $.store.get(SOUNDTRACK_KEY)) !== true) return
162 if (tool === 'cp_start_patch') {
163 const answer = await spotify($, START_SCRIPT, [sound.playlist])
164 await update($, soundtrackStarted, () => answer === 'started')
165 } else if (tool === 'cp_complete_patch' && sound.fadeOnDone && (await read($, soundtrackStarted))) {
166 await update($, soundtrackStarted, () => false)
167 await spotify($, FADE_SCRIPT, [])
168 }
169 } catch (err) {
170 $.ui.log(`patches: soundtrack failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
171 }
172}
173
174type LooseResult = { deny?: string; isError?: boolean }
175
176// Through $.tool.call with consent, so the permission path reads the press as Nae's request.
177const changePatch = async (
178 $: EngineInterface,
179 server: string,
180 aliases: Aliases,
181 tool: string,
182 row: BoardRow,
183 label: string,
184 sound: Sound,
185): Promise<void> => {
186 // Loosely typed: tsc gives up expanding every connected MCP tool's input types here (TS2589).
187 const ran = await ($.tool.call as unknown as (input: Record<string, unknown> & { tool: string }) => Promise<LooseResult>)({
188 tool: mcpToolName(server, tool),
189 patch_id: row.id,
190 consent: `Nae pressed "${label}" on "${row.title}" in /patches`,
191 })
192 if (ran.deny !== undefined || ran.isError === true) $.ui.toast(`Couldn't ${label.toLowerCase()} that patch.`)
193 else {
194 // Detached, so the board never waits on Spotify; a fade runs under the chime.
195 void soundtrackMusic($, tool, sound)
196 // Awaited, sound aside: toasts raised after the press settles skip this plugin's own toast queue.
197 if (tool === 'cp_complete_patch') await celebrate($, row.title, sound.chime)
198 }
199 await loadBoard($, server, aliases)
200}
201
202// Start from /fried: the same call as /patches' Start, then this list reloads too.
203const startFried = async ($: EngineInterface, server: string, aliases: Aliases, row: BoardRow, sound: Sound): Promise<void> => {
204 await changePatch($, server, aliases, 'cp_start_patch', row, 'Start', sound)
205 await loadFried($, server, aliases)
206}
207
208const handToCody = async ($: EngineInterface, row: BoardRow, pane: string): Promise<void> => {
209 await $.ui.close({ id: pane })
210 $.ui.toast(`→ Cody: ${row.title}`)
211 await $.prompt.submit({ text: handoffPrompt(row) })
212}
213
214export const registerPatchesPane = (on: On, options: PluginOptions): void => {
215 const server = typeof options.chaospatchServer === 'string' ? options.chaospatchServer : DEFAULT_SERVER
216 const aliases = parseAliases(options.projectAliases)
217 const sound: Sound = {
218 chime: options.doneChimeSound !== false,
219 playlist: playlistUri(options.soundtrackPlaylist),
220 fadeOnDone: options.soundtrackFadeOnDone !== false,
221 }
222
223 on('session.start', { isInteractive: true }, async ($, e, next) => {
224 const result = await next(e)
225 // Immediate: it only opens a pane and reads ChaosPatch, so it needn't wait for Cody's turn to end.
226 await $.command.register({ name: 'patches', description: "Open this repo's ChaosPatch board", immediate: true })
227 await $.command
228 .register({ name: 'fried', description: 'Low on energy? Open only the easy patches (energy:low), across projects' })
229 .catch((err: unknown) => $.ui.log(`patches: /fried not registered: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }))
230 return result
231 })
232
233 on('command.run', { command: 'patches' }, async $ => {
234 await update($, board, () => null)
235 void loadBoard($, server, aliases, 1)
236 await $.ui.open({ id: PANE, title: 'Patches', focus: true, closeOnEscape: true })
237 return {}
238 })
239
240 on('command.run', { command: 'fried' }, async $ => {
241 await update($, friedBoard, () => null)
242 void loadFried($, server, aliases)
243 await $.ui.open({ id: FRIED_PANE, title: 'Low-energy patches', focus: true, closeOnEscape: true })
244 return {}
245 })
246
247 on('ui.render', { component: 'Pane', requestId: FRIED_PANE }, async ($, e) => {
248 const { Box, Button, Text } = $.ui.resolve(e)
249 const shown = await read($, friedBoard)
250 if (shown === null) return <Text dimColor>Finding the easy ones…</Text>
251 if (shown.error !== undefined) return <Text dimColor>{shown.error}</Text>
252 if (shown.rows.length === 0) return <Text>{FRIED_EMPTY}</Text>
253 const here = shown.rows.filter(row => row.isHere)
254 return (
255 <Box flexDirection="column">
256 {shown.rows.map(row => {
257 const n = here.indexOf(row) + 1
258 return (
259 <Box key={`fried-${row.id}`} flexDirection="row" gap={1}>
260 <Box flexGrow={1} flexShrink={1}>
261 <Text wrap="truncate-end">
262 {row.title} <Text dimColor>· {row.project}</Text>
263 </Text>
264 </Box>
265 <Button key={`fstart-${row.id}`} onPress={() => startFried($, server, aliases, row, sound)}>
266 Start
267 </Button>
268 {row.isHere && (
269 <Button key={`fcody-${row.id}`} hotkey={String(n)} variant="primary" onPress={() => handToCody($, row, FRIED_PANE)}>
270 → Cody
271 </Button>
272 )}
273 </Box>
274 )
275 })}
276 <Text dimColor>{here.length > 0 ? `1–${here.length}: hand to Cody (this repo's) · ` : ''}Esc: close</Text>
277 </Box>
278 )
279 })
280
281 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
282 const { Box, Button, Text } = $.ui.resolve(e)
283 const shown = await read($, board)
284 if (shown === null) return <Text dimColor>Loading this repo's patches…</Text>
285 if (shown.error !== undefined) return <Text dimColor>{shown.error}</Text>
286 if (shown.inProgress.length === 0 && shown.review.length === 0 && shown.open.length === 0) {
287 return <Text dimColor>No open patches for this repo.</Text>
288 }
289 return (
290 <Box flexDirection="column">
291 {shown.inProgress.length > 0 && <Text bold>In progress</Text>}
292 {shown.inProgress.map(row => (
293 <Box key={`ip-${row.id}`} flexDirection="row" gap={1}>
294 <Box flexGrow={1} flexShrink={1}>
295 <Text wrap="truncate-end">🩹 {row.title}</Text>
296 </Box>
297 <Button key={`done-${row.id}`} onPress={() => changePatch($, server, aliases, 'cp_complete_patch', row, 'Done', sound)}>
298 Done
299 </Button>
300 </Box>
301 ))}
302 {shown.review.length > 0 && <Text bold>Awaiting review</Text>}
303 {shown.review.map(row => (
304 <Box key={`rv-${row.id}`} flexDirection="row" gap={1}>
305 <Box flexGrow={1} flexShrink={1}>
306 <Text wrap="truncate-end">👀 {row.title}</Text>
307 </Box>
308 <Button key={`approve-${row.id}`} onPress={() => changePatch($, server, aliases, 'cp_complete_patch', row, 'Done', sound)}>
309 Done
310 </Button>
311 </Box>
312 ))}
313 {shown.open.length > 0 && <Text bold>Open</Text>}
314 {shown.open.map((row, i) => (
315 <Box key={`open-${row.id}`} flexDirection="row" gap={1}>
316 <Text dimColor>{i + 1}</Text>
317 <Box flexGrow={1} flexShrink={1}>
318 <Text wrap="truncate-end">{row.title}</Text>
319 </Box>
320 <Button key={`start-${row.id}`} onPress={() => changePatch($, server, aliases, 'cp_start_patch', row, 'Start', sound)}>
321 Start
322 </Button>
323 <Button key={`cody-${row.id}`} hotkey={String(i + 1)} variant="primary" onPress={() => handToCody($, row, PANE)}>
324 → Cody
325 </Button>
326 </Box>
327 ))}
328 <Text dimColor>1–{shown.open.length || 1}: hand to Cody · Esc: close</Text>
329 </Box>
330 )
331 })
332}
333hooks/quick-patch.ts 133 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2import { DEFAULT_SERVER, matchesRepo, parseAliases, truncate, type Aliases } from './patch-status.ts'
3
4// #13 /patch: file a ChaosPatch patch from a rough thought, drafted from the session, without interrupting Cody.
5// Built against Claude Code 2.1.289.
6
7type Priority = 'low' | 'medium' | 'high'
8type Energy = 'low' | 'med' | 'high'
9export type Draft = { title: string; summary: string; acceptance: string[]; priority: Priority; energy?: Energy }
10type Project = { name: string; slug: string }
11
12const DRAFT_TIMEOUT_MS = 30_000
13const PRIORITIES: readonly string[] = ['low', 'medium', 'high']
14// Plain ChaosPatch tags (energy:low|med|high); /fried lists the low ones.
15const ENERGIES: readonly string[] = ['low', 'med', 'high']
16
17export const draftPrompt = (rough: string): string =>
18 [
19 'Nae just typed a quick patch idea mid-session. Turn it into a ChaosPatch patch, using this session for context.',
20 'Reply with ONLY a JSON object, no prose and no code fence:',
21 '{"title": string (under 80 chars), "summary": string (one line), "acceptance": string[] (2-4 short criteria), "priority": "low" | "medium" | "high", "energy": "low" | "med" | "high"}',
22 'Priority is "medium" unless her text clearly says otherwise. Stay true to what she wrote; don\'t add scope.',
23 'Energy is your guess at the focus it takes: "low" for a small copy, CSS, config or test tweak in one place; "high" for a new feature across files or an open design question; "med" otherwise.',
24 '',
25 `Her words: ${rough}`,
26 ].join('\n')
27
28// Strict on shape, lenient on wrapping: takes the outermost {...} in the reply.
29export const parseDraft = (reply: string): Draft | undefined => {
30 const start = reply.indexOf('{')
31 const end = reply.lastIndexOf('}')
32 if (start === -1 || end <= start) return undefined
33 try {
34 const d = JSON.parse(reply.slice(start, end + 1)) as Record<string, unknown>
35 const acceptance = Array.isArray(d.acceptance) ? d.acceptance.filter((a): a is string => typeof a === 'string' && a.trim() !== '') : []
36 if (typeof d.title !== 'string' || d.title.trim() === '' || typeof d.summary !== 'string') return undefined
37 const priority = typeof d.priority === 'string' && PRIORITIES.includes(d.priority) ? (d.priority as Priority) : 'medium'
38 const energy = typeof d.energy === 'string' && ENERGIES.includes(d.energy) ? (d.energy as Energy) : undefined
39 return { title: truncate(d.title.trim(), 100), summary: d.summary.trim(), acceptance, priority, ...(energy === undefined ? {} : { energy }) }
40 } catch {
41 return undefined
42 }
43}
44
45// Nae's words always ride along in `spec`, drafted or not.
46export const patchArgs = (projectSlug: string, rough: string, draft: Draft | undefined): Record<string, unknown> =>
47 draft === undefined
48 ? { project_slug: projectSlug, title: truncate(rough, 100), notes: rough, priority: 'medium', tags: ['quick-capture', 'rough'], spec: `Nae's words: ${rough}` }
49 : {
50 project_slug: projectSlug,
51 title: draft.title,
52 notes: [draft.summary, ...(draft.acceptance.length > 0 ? ['AC:', ...draft.acceptance.map(a => `- ${a}`)] : [])].join('\n'),
53 priority: draft.priority,
54 tags: ['quick-capture', ...(draft.energy === undefined ? [] : [`energy:${draft.energy}`])],
55 spec: `Nae's words: ${rough}`,
56 }
57
58const textOf = (result: { content: { text?: string }[] }): string => result.content.map(b => b.text ?? '').join('')
59
60// The fork sees the session (what we're working on); a brand-new session falls back to Haiku on her words alone.
61const draftPatch = async ($: EngineInterface, rough: string): Promise<Draft | undefined> => {
62 const prompt = draftPrompt(rough)
63 const forked = await $.model.fork({ prompt })
64 const reply =
65 forked.isAnswered
66 ? forked
67 : forked.reason === 'nothing-to-fork'
68 ? await $.model.complete({ model: 'haiku', prompt, effort: 'low', timeoutMs: DRAFT_TIMEOUT_MS })
69 : forked
70 if (!reply.isAnswered) {
71 $.ui.log(`patch: draft failed (${reply.reason}), filing raw`, { to: 'debug' })
72 return undefined
73 }
74 return parseDraft(reply.text)
75}
76
77const repoProject = async ($: EngineInterface, server: string, aliases: Aliases): Promise<Project | undefined> => {
78 const cwd = await $.session.cwd()
79 const result = await $.mcp.call(server, 'cp_list_projects', {})
80 if (result.isError) throw new Error(textOf(result))
81 const projects = JSON.parse(textOf(result)) as Project[]
82 return projects.find(p => matchesRepo(p.slug, p.name, cwd, aliases))
83}
84
85// Runs after the command returns, so the prompt is free at once. Every failure keeps her words on screen.
86const fileQuickPatch = async ($: EngineInterface, server: string, aliases: Aliases, rough: string): Promise<void> => {
87 try {
88 const project = await repoProject($, server, aliases)
89 if (project === undefined) {
90 $.ui.toast('No ChaosPatch project for this repo; kept your words.')
91 $.ui.log(`Not filed (no matching project). Your words: ${rough}`)
92 return
93 }
94 const drafted = await draftPatch($, rough).catch((err: unknown) => {
95 $.ui.log(`patch: draft threw: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
96 return undefined
97 })
98 const args = patchArgs(project.slug, rough, drafted)
99 const added = await $.mcp.call(server, 'cp_add_patch', args)
100 if (added.isError) throw new Error(textOf(added))
101 $.ui.toast(`🩹 Filed: ${truncate(String(args.title), 50)}${drafted === undefined ? ' (rough)' : ''}`)
102 } catch (err) {
103 $.ui.log(`patch: filing failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
104 $.ui.toast("Couldn't file that patch; your words are in the transcript.")
105 $.ui.log(`Not filed. Your words: ${rough}`)
106 }
107}
108
109export const registerQuickPatch = (on: On, options: PluginOptions): void => {
110 const server = typeof options.chaospatchServer === 'string' ? options.chaospatchServer : DEFAULT_SERVER
111 const aliases = parseAliases(options.projectAliases)
112
113 on('session.start', { isInteractive: true }, async ($, e, next) => {
114 const result = await next(e)
115 // Immediate: runs mid-turn, so a quick patch never waits on (or interrupts) Cody.
116 await $.command.register({
117 name: 'patch',
118 description: 'File a ChaosPatch patch from a rough thought: /patch <idea>',
119 argumentHint: '<idea>',
120 immediate: true,
121 })
122 return result
123 })
124
125 on('command.run', { command: 'patch' }, async ($, e) => {
126 const rough = e.args.trim()
127 if (rough === '') return { text: 'Usage: /patch <rough idea>' }
128 $.ui.toast('Drafting a patch…')
129 void fileQuickPatch($, server, aliases, rough)
130 return {}
131 })
132}
133hooks/reentry-card.tsx 168 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { ReentryCard } from '../types'
4import {
5 DEFAULT_SERVER,
6 PAGE_SIZE,
7 STARTUP_RETRY_MS,
8 formatElapsed,
9 parseAliases,
10 parsePatches,
11 patchesForCwd,
12 truncate,
13 type Aliases,
14 type Patch,
15} from './patch-status.ts'
16import { wrapKey, type LeftOff } from './wrap.ts'
17
18// #4 Context re-entry card: "last time / next" band above the prompt when a session opens.
19// Built against Claude Code 2.1.289.
20
21const card = atom({ plugin: 'codynd', key: 'reentryCard' } as const, null)
22const isHidden = atom({ plugin: 'codynd', key: 'reentryHidden' } as const, false)
23
24const UNIT = '\u001f'
25// ChaosPatch is often still connecting at session start (same race as patch-status.ts).
26const STARTUP_RETRIES = 6
27
28// `git log` line: subject, unit separator, relative date ("2 hours ago").
29export const parseLastCommit = (stdout: string): ReentryCard['lastCommit'] => {
30 const [subject, when] = stdout.trim().split(UNIT)
31 return subject && when ? { subject, when } : null
32}
33
34// Store values are untyped JSON: accept only a well-formed /wrap note.
35export const parseLeftOff = (value: unknown): LeftOff | null => {
36 const v = value as Partial<LeftOff> | undefined
37 return typeof v?.note === 'string' && typeof v.savedAt === 'number' ? { note: v.note, savedAt: v.savedAt } : null
38}
39
40export const pickNext = (inProgress: Patch[], open: Patch[]): ReentryCard['next'] => {
41 const [active] = inProgress
42 if (active !== undefined) return { title: active.title, isInProgress: true }
43 const [upNext] = open
44 return upNext === undefined ? null : { title: upNext.title, isInProgress: false }
45}
46
47const listPatches = async (
48 $: EngineInterface,
49 server: string,
50 args: Record<string, unknown>,
51 cwd: string,
52 aliases: Aliases,
53): Promise<Patch[]> => {
54 const patches: Patch[] = []
55 for (let offset = 0; ; offset += PAGE_SIZE) {
56 const result = await $.mcp.call(server, 'cp_list_all_patches', { ...args, limit: PAGE_SIZE, offset })
57 if (result.isError) throw new Error(result.content.map(b => b.text ?? '').join(' '))
58 const page = parsePatches(result.content.map(b => b.text ?? '').join(''))
59 patches.push(...page)
60 if (page.length < PAGE_SIZE) return patchesForCwd(patches, cwd, aliases)
61 }
62}
63
64// Each source fails on its own: a repo with no git history still gets its next step, and vice versa.
65// Resolves whether there was anything to show; nothing clears the card, so a stale one never lingers.
66// If ChaosPatch failed and `retries` remain (only the session-start load has any), the whole card
67// reloads soon so "Up next" can fill in. A reload rather than a merge: a timer's read of the card
68// can be stale. A dismissed card stays hidden; the render checks isHidden.
69const loadCard = async ($: EngineInterface, server: string, aliases: Aliases, retries = 0): Promise<boolean> => {
70 const cwd = await $.session.cwd()
71 let lastCommit: ReentryCard['lastCommit'] = null
72 let leftOff: ReentryCard['leftOff'] = null
73 let next: ReentryCard['next'] = null
74 try {
75 const git = await $.process.run(['git', 'log', '-1', `--format=%s${UNIT}%cr`])
76 if (git.exitCode === 0) lastCommit = parseLastCommit(git.stdout)
77 } catch (err) {
78 $.ui.log(`reentry-card: git failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
79 }
80 try {
81 const inProgress = await listPatches($, server, { status: 'in_progress' }, cwd, aliases)
82 // patchesForCwd re-sorts by started_at (null for open patches), keeping the priority order.
83 const open = inProgress.length > 0 ? [] : await listPatches($, server, { status: 'open', sort_by: 'priority' }, cwd, aliases)
84 next = pickNext(inProgress, open)
85 } catch (err) {
86 $.ui.log(`reentry-card: ChaosPatch failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
87 if (retries > 0) $.clock.after(STARTUP_RETRY_MS, () => void loadCard($, server, aliases, retries - 1))
88 }
89 try {
90 leftOff = parseLeftOff(await $.store.get(wrapKey(cwd)))
91 } catch (err) {
92 $.ui.log(`reentry-card: reading /wrap note failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
93 }
94 const hasAny = leftOff !== null || lastCommit !== null || next !== null
95 await update($, card, () => (hasAny ? { leftOff, lastCommit, next } : null))
96 return hasAny
97}
98
99const hide = async ($: EngineInterface): Promise<void> => {
100 await update($, isHidden, () => true)
101}
102
103export const registerReentryCard = (on: On, options: PluginOptions): void => {
104 const server = typeof options.chaospatchServer === 'string' ? options.chaospatchServer : DEFAULT_SERVER
105 const aliases = parseAliases(options.projectAliases)
106
107 on('session.start', { isInteractive: true }, async ($, e, next) => {
108 const result = await next(e)
109 void loadCard($, server, aliases, STARTUP_RETRIES)
110 await $.command
111 .register({
112 name: 'recap',
113 description: 'Bring the welcome-back card up again: left-off note, last commit, next patch',
114 immediate: true,
115 })
116 .catch((err: unknown) => $.ui.log(`reentry-card: /recap not registered: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' }))
117 return result
118 })
119
120 // For a window left open: reload the card fresh and show it until Dismiss or the next prompt.
121 on('command.run', { command: 'recap' }, async $ => {
122 if (!(await loadCard($, server, aliases))) return { text: 'Nothing to recap here yet.' }
123 await update($, isHidden, () => false)
124 return {}
125 })
126
127 // The card is for re-entry: once Nae types her first prompt, it has done its job.
128 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
129 await hide($)
130 return next(e)
131 })
132
133 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134 const shown = await read($, card)
135 if (shown === null || e.props.hasSurvey || (await read($, isHidden))) return next(e)
136 const { Box, Button, Text } = $.ui.resolve(e)
137 const now = await $.clock.now()
138 return (
139 <Box borderStyle="round" borderDimColor flexDirection="column" paddingX={1}>
140 <Text bold>↩ Welcome back</Text>
141 {shown.leftOff !== null && (
142 <Text>
143 <Text dimColor>Left off: </Text>
144 {shown.leftOff.note} <Text dimColor>· {formatElapsed(now - shown.leftOff.savedAt)} ago</Text>
145 </Text>
146 )}
147 {shown.lastCommit !== null && (
148 <Text wrap="truncate-end">
149 <Text dimColor>Last time: </Text>
150 {truncate(shown.lastCommit.subject)} <Text dimColor>· {shown.lastCommit.when}</Text>
151 </Text>
152 )}
153 {shown.next !== null && (
154 <Text wrap="truncate-end">
155 <Text dimColor>{shown.next.isInProgress ? 'In progress: ' : 'Up next: '}</Text>
156 🩹 {truncate(shown.next.title)}
157 </Text>
158 )}
159 <Box>
160 <Button key="dismiss" role="dismiss" onPress={() => hide($)}>
161 Dismiss
162 </Button>
163 </Box>
164 </Box>
165 )
166 })
167}
168