SLOPSHOPPER

codynd

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

newpanebandguardcommandtoast
v0.30.1MITupdated 2026-10-06lmdrew96/codynd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codynd
│ ┃ Patches ✕ › fix the failing auth test an╭────────────────────────────────────╮ │ ┃ Couldn't reach ChaosPatch: undefined is not │ codynd │ │ ┃ an object (evaluating 'result.content.map') ● codynd: next-event: refresh │ Toolbox manifest updated · 0 items │ │ ● codynd: reentry-card: ChaosP╰────────────────────────────────────╯ │ ⏺ Read(src/auth.ts) ╭────────────────────────────────╮ │ ⎿ Read 6 lines │ codynd │ │ ⏺ Update(src/auth.ts) │ Writing up where you left off… │ │ ⎿ Added 2 lines, removed 1 line╰────────────────────────────────╯ │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /toolbox-sync │ ● codynd: patch-status: refresh failed: undefined is not an object (e │ ● codynd: wins: date could not report local midnight │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ↩ Welcome back │ │ Left off: OK · <1m ago │ │ [ Dismiss ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ codynd: ⏱ <1m in

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ↩ Welcome back │ │ Left off: OK · <1m ago │ │ [ Dismiss ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Patches
Couldn't reach ChaosPatch: undefined is not an object (evaluating 'result.content.map')
Pane · Low-energy patches
Couldn't reach ChaosPatch.
README

CodyND

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.

The mods

ModWhat it does
Patch status lineShows 🩹 <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 chimeWhen 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 chimeA 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 decoderWhen 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 hooksQuiet 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 slotWhen 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 countdownYour 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 clockAfter 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 cardA 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.
/winsToday'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.
/whatchangedWhat 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.
/patchesOpens 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.
/friedFor 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 manifestWrites 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).

Install

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.

Requirements

  • Claude Code with mod support. The mod API is early access and can change between releases. CodyND was built against Claude Code 2.1.289.
  • A ChaosPatch MCP server for the status line, the chime, /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.
  • A Kindling MCP server for /park.
  • A ControlledChaos MCP server for the next-event countdown.
  • /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.
  • macOS for the sound. The chime plays through 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.

Settings

Change these in /config (they're stored under pluginConfigs.codynd in your settings).

SettingDefaultWhat it does
chaospatchServerclaude.ai ChaosPatchYour 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).
doneChimeSoundtruePlay the chime when a patch is completed or sent to review.
landingChimeSoundtruePlay the cabin chime with the 15-minute heads-up before your next event.
attentionChimeSoundtruePlay the attention chime when Claude needs you.
soundtrackPlaylistSpotify's Deep FocusWhat the soundtrack plays when a patch starts: a spotify: URI or an open.spotify.com link.
soundtrackFadeOnDonetrueFade out and pause the soundtrack's music when a patch is completed.
bodyCheckMinutes90Minutes of continuous work before the body-check nudge.
kindlingServerclaude.ai KindlingYour Kindling MCP server's name, for /park.
controlledChaosServerclaude.ai ControlledChaosYour ControlledChaos MCP server's name, for the next-event countdown.

Commands

CommandWhat it does
/patchesOpen this repo's ChaosPatch board.
/friedOpen 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.
/recapBring the re-entry card back up, freshly loaded.
/patch <idea>Draft and file a patch for this repo from a rough idea.
/winsToday's closed patches and commits in this repo.
/wins-allToday's closed patches across every project.
/wins-weekThis 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-syncRewrite the toolbox manifest now.

Development

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)

License

MIT. Part of ADHDesigns.

Source 20 files
hooks/register.ts 41 lines
1import 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}
41
hooks/attention-chime.ts 48 lines
1import 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 lines
1import { 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}
164
hooks/error-decoder.ts 133 lines
1import 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}
133
hooks/focus.tsx 169 lines
1import { 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}
169
hooks/instructions.ts 84 lines
1import { 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}
84
hooks/next-event.ts 122 lines
1import { 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}
122
hooks/park.ts 53 lines
1import 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}
53
hooks/patch-status.ts 213 lines
1import { 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}
213
hooks/patches-pane.tsx 333 lines
1import { 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}
333
hooks/quick-patch.ts 133 lines
1import 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}
133
hooks/reentry-card.tsx 168 lines
1import { 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