A menu for the tool mods in this repo and for your own commands: a button in the footer under the prompt box opens a menu pane, docked on the right, with a…

A Claude Code mod that puts the tool mods in this repo, and your own skills and commands, one press away. A [ ◆ Arsenal ▾ ] button in Claude's orange, in the footer on the first row under the prompt box, opens a menu pane docked on the right with a Prompt Upgrade switch, a Sound notifications switch with a sound for you and one for Claude, shortcuts to /prompt-upgrade and /work-report, and a USER and a PROJECT section listing the skills and commands you wrote. You no longer have to remember /prompt-upgrade history or /work-report week, or what you named your own commands, and you can look away while Claude works: a sound tells you when it is done or needs you.
Credit. The idea comes from a YouTube video by Tristen O'Brien (tristenobrien.ai), who also gave us the idea for prompt-upgrade. In it, a "Tristen's Tools ▼" button in the footer opens a menu of settings and shortcuts. arsenal is that menu for the tools in this repo, under a name that is not a person's. The sound notifications are an idea from angomedia/claude-mods (their done-chime, MIT); the code here is written fresh for Windows. Why it is built the way it is: DESIGN.md.
(the other mods' bands)
────────────────────────────────────────────
> your prompt
────────────────────────────────────────────
[ ◆ Arsenal ▴ ] ◎ plan 44% · ~2m
Pressing the button opens the pane, docked on the right:
┌ Arsenal ─────────────────────────────
│ ◆ A R S E N A L Opus 5.5 · High
│ ────────────────────────────────────
│ ── S E T T I N G S
│ ○ Prompt Upgrade ○ Off
│ upgrade every prompt first
│ ● Sound notifications ● On
│ ♪ Claude Windows Notify ▸
│ ↗ You none ▸
│ ── L A U N C H
│ u: ✦ Upgrade a prompt
│ h: ◷ Prompt history
│ t: ▤ Today's report
│ w: ▥ This week's report
│ s: ⚙ Report settings
│ ── U S E R ✎ edit
│ /handoff Use when the u… …
│ /standup Yesterday's work
│ ── P R O J E C T ✎ edit
│ /seed-db Reset the local d…
└──────────────────────────────────────
| Do | Result |
|---|---|
Press [ ◆ Arsenal ▾ ], in the footer on the first row under the prompt box | Opens the menu pane, docked on the right in fullscreen (above the prompt otherwise). The button turns to ▴; press it again to close. |
/arsenal | The same toggle from the keyboard. It works while Claude is busy. |
| Esc, or the pane's close mark | Closes it, and the button turns back to ▾. |
| Row | Runs |
|---|---|
Prompt Upgrade switch (p) | /prompt-upgrade on or /prompt-upgrade off. The pill shows the real setting: ● On or ○ Off. |
Sound notifications switch (c) | Turns the sounds on or off, as /chime on and /chime off do. |
♪ Claude (o) | Only while the sounds are on. Picks the next sound in the list for Claude's sound and plays it. |
↗ You (m) | Only while the sounds are on. Picks the next sound for your own Enter and plays it; none comes first and plays nothing. |
✦ Upgrade a prompt (u) | /prompt-upgrade |
◷ Prompt history (h) | /prompt-upgrade history |
▤ Today's report (t) | /work-report today |
▥ This week's report (w) | /work-report week |
⚙ Report settings (s) | /work-report settings |
Click a row, or focus the pane and press its letter.
The last line of the pane is dim and reads arsenal v0.4.6: the version running.
sent: /prompt-upgrade on, then ran: /prompt-upgrade on.Opus 5.5 · High), or the model alone for a model without effort. It only reads them. arsenal never changes the model or the effort.arsenal reads the session's command list and keeps only the tools' rows whose command exists. With only prompt-upgrade installed there are no work-report rows, and the other way round. The Sound notifications rows need no tool, so the button and SETTINGS are always there.
Off until you turn them on: press the switch (c), or /chime on. There are two sounds, as in iMessage:
none until you pick one. A slash command, a prompt another mod sends for you (Prompt Upgrade's upgrade) and a prompt you never see play nothing. It plays as you press Enter, even if another mod then holds the prompt back.Each row goes down the same list: your own sounds first, then the system's (Windows Notify Messaging, Windows Notify System Generic, Windows Message Nudge, Windows Ding, Windows Background; on a Mac Glass, Ping, Pop, Hero, Tink). Each press plays the sound it lands on. The ↗ You row has none before the list.
Your own sounds. Put .wav or .mp3 files in ~/.claude/arsenal/sounds/ (C:\Users\<you>\.claude\arsenal\sounds\ on Windows). On a Mac .m4a, .aiff and .caf files work too. Arsenal makes the folder whenever a session starts without it, with a README.txt that says the same; a folder that is already there is never touched. Each file name, without the extension, is a choice in both lists, and the folder is read again every time the menu opens. Keep them short: a sound is cut off after 15 seconds. If a .wav and an .mp3 have one name, the .wav is the one listed. If the sound you picked for Claude is deleted, the default plays; if it was the Sent sound, that is none again. (0.4.0 treated a file called question.wav as a separate question sound. It is now a sound like any other.)
Apple's original sounds are not in this repo, because they are Apple's. If you already have the two iMessage sounds as .mp3 (many sound-effect sites offer them), drop those in the folder as they are. Otherwise copy them from a Mac and convert them once, because Windows cannot play .caf or .aiff: afconvert -f WAVE -d LEI16 in.caf out.wav. The new-message tone is ReceivedMessage.caf in /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/ (pick it for Claude). Where the sent swoosh is depends on the macOS version, so search your Mac for it (Spotlight, sent with a .caf or .aiff file) and pick it for You. Name the converted files what you like, for example imessage-sent.wav and imessage-new.wav. On Windows only .wav and .mp3 are listed. An .mp3 goes through a heavier Windows player than a .wav, so it may start a little later; the delay was not measured by ear.
| Command | Does |
|---|---|
/chime | Says whether the sounds are on, which sound each row has, and the names to pick from. |
/chime on, /chime off | The switch, from the keyboard. They work while Claude is busy. |
/chime sound <name> | Picks Claude's sound by name (any case) and plays it. |
/chime sent <name>, /chime sent none | Picks the sound for your own Enter (the ↗ You row) and plays it, or turns it off. |
/chime test | Plays Claude's sound, even while the sounds are off. |
The switch and both sounds are saved on this machine. Sounds play through PowerShell on Windows and afplay on a Mac; on Linux nothing plays.
On a Mac. Apple's Glass, Ping, Pop, Hero and Tink are listed after your own sounds, and a Mac is recognised by /System/Library/Sounds being there, whatever your home folder is called. Apple's iMessage sounds need no conversion: afplay plays .caf, so copy them into ~/.claude/arsenal/sounds/ as they are (paths above). Checked on a Mac on 2026-10-24 (0.4.5, in the Terminal and on Desktop): the sounds play. The tests cover the command it runs (afplay <file>).
Under LAUNCH, two sections list what you wrote yourself:
~/.claude/skills/<name>/SKILL.md and ~/.claude/commands/<name>.md.A section with nothing in it is not drawn. Plugins' skills and commands, built-ins, MCP prompts, the claude.ai-synced skills and commands in subfolders are not listed, and neither is a skill with user-invocable: false. A name that is in both places is shown under USER, because that is the one /name runs. arsenal keeps only what the engine itself lists as yours, so a row is never a file that did nothing when pressed.
| Do | Result |
|---|---|
| Press a row | Closes the pane and runs the command bare, as if you had typed /name. |
Press the … at the end of a row | Closes the pane and puts /name in the prompt box for you to finish. Only commands with an argument-hint have one. A draft already in the box is never written over: you get a toast instead. |
Press ✎ edit on a section's header | Shows every command in the section with a ● On / ○ Off pill. A press flips it at once and saves it. ✓ done goes back. One section is in edit mode at a time, and closing the pane leaves it. |
Everything is on at first. What you turn off is saved on this machine (so a home and a work PC keep separate lists), and a command you add later shows up by itself. PROJECT's list belongs to the repo: a command turned off in a repo stays off in each of its worktrees and subfolders, and does not touch a same-named command in another repo. A section whose commands are all off still shows its header, so ✎ edit can turn them back on. The folders are read again each time the menu opens.
These rows have no hotkeys. LAUNCH keeps its own.
ctx, subagent-progress and agent-pulse keep their bands. The button is not in the band at all; it sits in the footer, before the labels other mods add there, such as waypoints' plan.claude plugin marketplace add afterever/claude-mods
claude plugin install arsenal@afterever-mods
Restart Claude Code afterwards. Install prompt-upgrade and work-report from the same marketplace for the LAUNCH rows to appear; the sound notifications, USER and PROJECT need nothing else.
claude --plugin-dir E:\github\claude-mods\arsenal
claude plugin validate E:\github\claude-mods\arsenal
claude plugin test E:\github\claude-mods\arsenal
hooks/menu.ts is the arithmetic and wording (model and effort labels, which rows, the columns, off lists, row layout) and hooks/menu.test.ts tests it. hooks/own.ts is the pure part of finding your own commands (frontmatter, names, the project key) and hooks/own.test.ts tests it. hooks/chime.ts is the pure part of the sound notifications (the sound list, the player's command line, the saved setting, /chime's arguments) and hooks/chime.test.ts tests it. hooks/register.tsx is the hooks, including the folder and git reads, and hooks/render.test.ts runs them in the engine's test kit on the terminal and the desktop surface.
The button is a SessionMode hook: it wraps the engine's footer (await next(e)), which already holds the labels other mods added, and draws itself first with one space between. A mod that draws its own tree in that slot would replace the row below it, so add a label to modes instead, as waypoints does. DESIGN.md has the probe under "0.3.0: the launcher moves to the footer".
hooks/register.tsx 799 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ArsenalChime, ArsenalOff, ArsenalOwn, ArsenalTools } from '../types'
5import {
6 CHIME_HELP,
7 CHIME_STORE_KEY,
8 MAC_SOUNDS_FOLDER,
9 NO_CHIME,
10 chimeFromStore,
11 chimeToStore,
12 chosen,
13 describeChime,
14 listOf,
15 nextName,
16 nextSent,
17 osFrom,
18 parseChime,
19 personalSounds,
20 playerFor,
21 sentChoice,
22 shouldPlaySent,
23 soundsFolder,
24 soundsReadme,
25 systemCandidates,
26} from './chime'
27import type { Os, Sound, Sounds } from './chime'
28import {
29 NO_OFF,
30 NO_OWN,
31 commandLine,
32 draftNote,
33 failedNote,
34 flipped,
35 hasPromptUpgrade,
36 headerNote,
37 launchColumns,
38 launchItems,
39 nameCells,
40 offFor,
41 ownRow,
42 paneRows,
43 ranNote,
44 reconcile,
45 runningFrom,
46 sameRunning,
47 sectionTitle,
48 sentNote,
49 shownRows,
50 toolsFrom,
51 unfilledNote,
52 versionLine,
53 withOff,
54} from './menu'
55import type { LaunchItem, Section } from './menu'
56import { VERSION } from './version'
57import { STORE_KEY, confirmed, offFromStore, offToStore, projectPlace, readCandidate, sameFolder, withoutUser } from './own'
58import type { Candidate, Found, GitAnswers } from './own'
59
60// What the drawings read lives in $.state. Of it only `off` is also kept, in
61// $.store (a $.store read made while drawing does not subscribe the drawing, a
62// $.state read does), and session.start (which also runs on a hot reload)
63// reads isOpen from the panes
64const isOpenAtom = atom({ plugin: 'arsenal', key: 'isOpen' } as const, false)
65const runningAtom = atom({ plugin: 'arsenal', key: 'running' } as const, null)
66const NO_TOOLS: ArsenalTools = { 'prompt-upgrade': false, 'work-report': false }
67const toolsAtom = atom({ plugin: 'arsenal', key: 'tools' } as const, NO_TOOLS)
68const noteAtom = atom({ plugin: 'arsenal', key: 'note' } as const, '')
69const ownAtom = atom({ plugin: 'arsenal', key: 'own' } as const, NO_OWN)
70const offAtom = atom({ plugin: 'arsenal', key: 'off' } as const, NO_OFF)
71const editingAtom = atom({ plugin: 'arsenal', key: 'editing' } as const, null)
72const projectKeyAtom = atom({ plugin: 'arsenal', key: 'projectKey' } as const, '')
73const chimeAtom = atom({ plugin: 'arsenal', key: 'chime' } as const, NO_CHIME)
74
75// The menu is a pane, docked at the right in fullscreen like the tools' own
76const PANE = 'arsenal'
77const PANE_COLUMNS = 48
78const paneArgs = (rows: number) => ({ id: PANE, title: 'Arsenal', closeOnEscape: true, columns: PANE_COLUMNS, rows }) as const
79
80const COLORS = {
81 accent: '#F2A65A',
82 teal: '#2EC4B6',
83 off: '#3A404A',
84 dark: '#0D1117',
85 light: '#E6EDF3',
86}
87
88const plain = (err: unknown) => (err instanceof Error && err.message ? err.message : 'something went wrong')
89
90// ── State helpers ────────────────────────────────────────────────────────
91
92const say = ($: EngineInterface, text: string) => update($, noteAtom, () => text)
93
94async function refreshTools($: EngineInterface) {
95 try {
96 const names = (await $.command.list()).map(c => c.name)
97 await update($, toolsAtom, () => toolsFrom(names))
98 } catch {
99 // no rows is the quiet answer: the launcher is not drawn
100 }
101}
102
103/** The model `/model` names, since the last request. Read as the menu opens. */
104async function refreshModel($: EngineInterface) {
105 try {
106 const model = await $.session.model()
107 await update($, runningAtom, r => {
108 const next = reconcile(r ?? null, model)
109 return sameRunning(r ?? null, next) ? (r ?? null) : next
110 })
111 } catch {
112 // the header keeps what it had
113 }
114}
115
116// ── The person's own commands ────────────────────────────────────────────
117
118/** What `git rev-parse <args>` prints, first line; null when git says no, is missing or is slow. */
119async function gitLine($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
120 try {
121 const r = await $.process.run(['git', 'rev-parse', ...args], { cwd, timeoutMs: 5000 })
122 return r.exitCode === 0 ? (r.stdout.split(/\r?\n/)[0]?.trim() || null) : null
123 } catch {
124 return null
125 }
126}
127
128/**
129 * The repo's top level and shared `.git` folder, as seen from `root`. A git
130 * older than 2.31 has no `--path-format`, so its answer is asked again without
131 * it; `projectPlace` reads a relative one against `root`.
132 */
133async function askGit($: EngineInterface, root: string): Promise<GitAnswers> {
134 const [top, absolute] = await Promise.all([
135 gitLine($, root, ['--show-toplevel']),
136 gitLine($, root, ['--path-format=absolute', '--git-common-dir']),
137 ])
138 return { top, common: absolute ?? (top ? await gitLine($, root, ['--git-common-dir']) : null) }
139}
140
141/** Every entry of a folder, or none when it is not there. */
142const entriesOf = async ($: EngineInterface, path: string) => {
143 try {
144 return await $.fs.list(path)
145 } catch {
146 return []
147 }
148}
149
150/**
151 * What the files of one `.claude` folder say: `skills/<folder>/SKILL.md`, one
152 * level down, and `commands/<file>.md`, at the top. A link is tried as well,
153 * since the engine's list confirms what is really there. Commands in
154 * subfolders (`dir:name`) are left out of 0.2.0.
155 */
156async function collect($: EngineInterface, base: string): Promise<Found[]> {
157 const [skills, commands] = await Promise.all([entriesOf($, `${base}/skills`), entriesOf($, `${base}/commands`)])
158 const files: { kind: Candidate['kind']; fallback: string; path: string }[] = [
159 ...skills.filter(s => s.kind === 'dir' || s.isLink).map(s => ({ kind: 'skill' as const, fallback: s.name, path: `${base}/skills/${s.name}/SKILL.md` })),
160 ...commands
161 .filter(c => (c.kind === 'file' || c.isLink) && /\.md$/i.test(c.name))
162 .map(c => ({ kind: 'command' as const, fallback: c.name.slice(0, -3), path: `${base}/commands/${c.name}` })),
163 ]
164 const read = await Promise.all(
165 files.map(async f => {
166 try {
167 const text = await $.fs.read(f.path)
168 return typeof text === 'string' ? readCandidate({ kind: f.kind, fallback: f.fallback, text }) : null
169 } catch {
170 return null
171 }
172 }),
173 )
174 return read.filter((f): f is Found => f !== null)
175}
176
177/**
178 * Finds the person's own commands and the project they belong to. Run at
179 * session start and each time the menu opens, so a skill added mid-session
180 * shows up, and a `/cd` or worktree move takes PROJECT and its off list along.
181 * A failure leaves what the last scan found.
182 */
183async function scan($: EngineInterface) {
184 try {
185 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
186 const root = await $.session.root()
187 const place = projectPlace(root, await askGit($, root))
188 const listed = await $.command.list()
189 const user = home ? confirmed(await collect($, `${home}/.claude`), listed) : []
190 // in the home folder, `.claude` is USER's own: there is no PROJECT
191 const project = home && sameFolder(place.folder, home) ? [] : withoutUser(confirmed(await collect($, `${place.folder}/.claude`), listed), user)
192 await update($, ownAtom, () => ({ user, project }) satisfies ArsenalOwn)
193 await update($, projectKeyAtom, () => place.key)
194 } catch {
195 // the rows stay as they were
196 }
197}
198
199/** What was turned off, from the last session on this machine. */
200async function loadOff($: EngineInterface) {
201 try {
202 const held = offFromStore(await $.store.get(STORE_KEY))
203 await update($, offAtom, () => held)
204 } catch {
205 // nothing is off until the person turns something off
206 }
207}
208
209// ── Sound notifications ──────────────────────────────────────────────────
210
211/** Windows by its environment; a Mac by Apple's sound folder or its home. The folder is asked only off Windows. */
212async function osOf($: EngineInterface, home: string): Promise<Os> {
213 const windowsEnv = await $.env.get('OS')
214 if (windowsEnv === 'Windows_NT') return 'windows'
215 try {
216 return osFrom(windowsEnv, home, await $.fs.exists(MAC_SOUNDS_FOLDER))
217 } catch {
218 return osFrom(windowsEnv, home)
219 }
220}
221
222/**
223 * The sounds there are now: the sound files of the person's folder and the
224 * system's own that exist on disk. Read each time it is needed, so a file
225 * dropped in mid-session is found.
226 */
227async function soundsOf($: EngineInterface): Promise<{ sounds: Sounds; os: Os }> {
228 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
229 const os = await osOf($, home)
230 const folder = soundsFolder(home)
231 const fileNames = home ? (await entriesOf($, folder)).filter(f => f.kind === 'file' || f.isLink).map(f => f.name) : []
232 const found = await Promise.all(
233 systemCandidates(os).map(async (s): Promise<Sound | null> => {
234 try {
235 return (await $.fs.exists(s.path)) ? s : null
236 } catch {
237 return null
238 }
239 }),
240 )
241 return { sounds: { personal: personalSounds(fileNames, folder, os), system: found.filter((s): s is Sound => s !== null) }, os }
242}
243
244/**
245 * Makes the person's sounds folder when it is missing, with a README.txt in it
246 * (a write creates the folders above it). A folder that is there is left as it
247 * is, empty or not. A failure leaves no folder; the sounds still work without.
248 */
249async function ensureSoundsFolder($: EngineInterface) {
250 try {
251 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
252 if (!home) return
253 const folder = soundsFolder(home)
254 if (await $.fs.exists(folder)) return
255 await $.fs.write(`${folder}/README.txt`, soundsReadme(await osOf($, home)))
256 } catch {
257 // no folder, and no harm
258 }
259}
260
261/** What was chosen, from the last session on this machine. */
262async function loadChime($: EngineInterface) {
263 try {
264 const held = chimeFromStore(await $.store.get(CHIME_STORE_KEY))
265 await update($, chimeAtom, () => held)
266 } catch {
267 // the chime is off until the person turns it on
268 }
269}
270
271/** Writes the setting to state (the drawing follows), then to the store. */
272async function setChime($: EngineInterface, next: ArsenalChime) {
273 await update($, chimeAtom, () => next)
274 try {
275 await $.store.set(CHIME_STORE_KEY, chimeToStore(next))
276 } catch {
277 // the state stands for this session
278 $.ui.toast('Could not save the chime setting.')
279 }
280}
281
282/** Plays one sound file. Silent when nothing can play it or the player fails. */
283async function playPath($: EngineInterface, path: string | undefined, os: Os) {
284 const argv = path ? playerFor(path, os) : null
285 if (!argv) return
286 try {
287 await $.process.run(argv, { timeoutMs: 15000 })
288 } catch {
289 // no player, or the sound is gone: stay quiet
290 }
291}
292
293/** Starts a sound without holding the caller: nothing here may slow or fail a turn. */
294const quiet = (run: () => Promise<void>) => {
295 void run().catch(() => {})
296}
297
298/** The sound for an event: Claude's (a reply, a question, a permission prompt) or the person's own Enter. Nothing while the chime is off. */
299async function playFor($: EngineInterface, kind: 'claude' | 'sent') {
300 const chime = (await read($, chimeAtom)) ?? NO_CHIME
301 if (!chime.isOn) return
302 const { sounds, os } = await soundsOf($)
303 await playPath($, (kind === 'claude' ? chosen(sounds, chime.sound) : sentChoice(sounds, chime.sent))?.path, os)
304}
305
306async function openMenu($: EngineInterface) {
307 await refreshModel($)
308 await scan($)
309 const rows = paneRows(
310 await read($, toolsAtom),
311 (await read($, ownAtom)) ?? NO_OWN,
312 (await read($, offAtom)) ?? NO_OFF,
313 (await read($, projectKeyAtom)) ?? '',
314 null,
315 PANE_COLUMNS,
316 (await read($, chimeAtom)) ?? NO_CHIME,
317 )
318 await update($, isOpenAtom, () => true)
319 const { isPlaced } = await $.ui.open(paneArgs(rows))
320 if (!isPlaced) {
321 await update($, isOpenAtom, () => false)
322 $.ui.toast('Arsenal needs a wider window to open.')
323 }
324}
325
326async function closeMenu($: EngineInterface) {
327 await update($, isOpenAtom, () => false)
328 await update($, editingAtom, () => null)
329 await $.ui.close({ id: PANE })
330}
331
332async function toggleMenu($: EngineInterface) {
333 if ((await read($, isOpenAtom)) === true) await closeMenu($)
334 else await openMenu($)
335}
336
337/**
338 * prompt-upgrade's `auto`, which is not in this plugin's contract. Anything
339 * but `true` is off, which includes prompt-upgrade not being installed. The
340 * read made while drawing subscribes the drawing, so the pill follows the
341 * value once prompt-upgrade writes it.
342 */
343async function readAuto($: EngineInterface): Promise<boolean> {
344 try {
345 const held = await ($.state as any).get({ plugin: 'prompt-upgrade', key: 'auto' })
346 return held?.value === true
347 } catch {
348 return false
349 }
350}
351
352// ── Presses ──────────────────────────────────────────────────────────────
353
354/**
355 * Runs a tool's own slash command, as if typed. A Launch row closes the menu
356 * first, so the tool's own pane takes the dock; a failure is then a toast,
357 * since the note is out of view. The switch keeps the menu open and says what
358 * it sent and how that went in the note line.
359 */
360async function press($: EngineInterface, command: string, args: string, closes: boolean) {
361 const line = commandLine(command, args)
362 if (closes) await closeMenu($)
363 else await say($, sentNote(line))
364 try {
365 await $.command.run({ command, args })
366 if (!closes) await say($, ranNote(line))
367 } catch (err) {
368 const failed = failedNote(line, plain(err))
369 if (closes) $.ui.toast(failed)
370 else await say($, failed)
371 }
372}
373
374/**
375 * The `…` on a row: puts `/name ` in the prompt box for the person to finish.
376 * A draft in the box is never written over, and a box that cannot take the text
377 * is said so. The menu closes first, so the toast is seen.
378 */
379async function fillPrompt($: EngineInterface, name: string) {
380 await closeMenu($)
381 try {
382 if ((await $.prompt.read()).text.trim() !== '') {
383 $.ui.toast(draftNote(name))
384 return
385 }
386 if (!(await $.prompt.fill({ text: `/${name} ` })).isFilled) $.ui.toast(unfilledNote)
387 } catch {
388 $.ui.toast(unfilledNote)
389 }
390}
391
392/** A pill in edit mode: turns the command off or on at once, and saves it. */
393async function flipOff($: EngineInterface, section: Section, name: string) {
394 const key = (await read($, projectKeyAtom)) ?? ''
395 const held = (await read($, offAtom)) ?? NO_OFF
396 const next: ArsenalOff = withOff(held, section, key, flipped(offFor(held, section, key), name))
397 await update($, offAtom, () => next)
398 try {
399 await $.store.set(STORE_KEY, offToStore(next))
400 } catch {
401 // the state stands for this session
402 $.ui.toast('Could not save which commands are off.')
403 }
404}
405
406/** The Sound row: the next sound in the list, saved and played at once. */
407async function cycleSound($: EngineInterface) {
408 const held = (await read($, chimeAtom)) ?? NO_CHIME
409 const { sounds, os } = await soundsOf($)
410 const name = nextName(sounds, held.sound)
411 if (!name) return
412 await setChime($, { ...held, sound: name })
413 await playPath($, listOf(sounds).find(s => s.name === name)?.path, os)
414}
415
416/** The Sent row: the next sound after none and the list, saved and played at once; none plays nothing. */
417async function cycleSent($: EngineInterface) {
418 const held = (await read($, chimeAtom)) ?? NO_CHIME
419 const { sounds, os } = await soundsOf($)
420 const name = nextSent(sounds, held.sent)
421 await setChime($, { ...held, sent: name })
422 await playPath($, sentChoice(sounds, name)?.path, os)
423}
424
425/** `✎ edit` and `✓ done`: one section is in edit mode at a time. */
426const setEditing = ($: EngineInterface, section: Section | null) => update($, editingAtom, () => section)
427
428// ── Drawings ─────────────────────────────────────────────────────────────
429
430type View = {
431 tools: ArsenalTools
432 auto: boolean
433 header: string
434 note: string
435 own: ArsenalOwn
436 off: ArsenalOff
437 editing: Section | null
438 projectKey: string
439 chime: ArsenalChime
440 /** The name the Claude row shows: the chosen sound, or the default when the saved one is gone. */
441 sound: string
442 /** The name the You row shows: the chosen sound, or empty for none (also when the saved one is gone). */
443 sentSound: string
444}
445
446function drawMenu($: EngineInterface, e: Parameters<typeof $.ui.resolve>[0], bodyColumns: number, view: View) {
447 const { Box, Button, Text } = $.ui.resolve(e)
448 const { tools, auto } = view
449 const inner = Math.max(20, bodyColumns)
450 const columns = launchColumns(tools, bodyColumns)
451 const columnWidth = columns.length > 1 ? Math.floor(inner / columns.length) : inner
452
453 // `● On` / `○ Off`, as the Prompt Upgrade switch and the edit-mode rows show it
454 const pill = (isOn: boolean) => (
455 <Text backgroundColor={isOn ? COLORS.teal : COLORS.off} color={isOn ? COLORS.dark : COLORS.light}>
456 {isOn ? ' ● On ' : ' ○ Off '}
457 </Text>
458 )
459
460 // USER and PROJECT: a header with `✎ edit`, then a row per command that is on
461 // (`all off` when none is), or in edit mode every command with its pill
462 const ownSection = (section: Section, title: string) => {
463 const commands = view.own[section]
464 if (commands.length === 0) return null
465 const off = offFor(view.off, section, view.projectKey)
466 const isEditing = view.editing === section
467 const shown = shownRows(commands, off, false)
468 const nameWidth = nameCells(shown.length ? shown : commands, inner)
469 return (
470 <Box key={`arsenal-${section}-section`} flexDirection="column">
471 <Box justifyContent="space-between">
472 <Text dimColor>{sectionTitle(title)}</Text>
473 <Button key={`arsenal-${section}-edit`} plain label={isEditing ? '✓ done' : '✎ edit'} onPress={() => setEditing($, isEditing ? null : section)} />
474 </Box>
475 {isEditing
476 ? commands.map(c => (
477 <Box key={`arsenal-${section}-edit-row-${c.name}`} justifyContent="space-between">
478 <Text dimColor={off.includes(c.name)}>{`/${c.name}`}</Text>
479 <Button key={`arsenal-${section}-pill-${c.name}`} plain onPress={() => flipOff($, section, c.name)}>
480 {pill(!off.includes(c.name))}
481 </Button>
482 </Box>
483 ))
484 : shown.length === 0
485 ? <Text dimColor>{' all off'}</Text>
486 : shown.map(c => {
487 const { label, detail } = ownRow(c, nameWidth, inner)
488 return (
489 <Box key={`arsenal-${section}-row-${c.name}`} justifyContent="space-between">
490 <Button key={`arsenal-${section}-run-${c.name}`} plain onPress={() => press($, c.name, '', true)}>
491 <Text>{label}</Text>
492 <Text dimColor>{detail}</Text>
493 </Button>
494 {c.takesArgs ? <Button key={`arsenal-${section}-more-${c.name}`} plain label="…" onPress={() => fillPrompt($, c.name)} /> : null}
495 </Box>
496 )
497 })}
498 </Box>
499 )
500 }
501
502 const launchRow = (item: LaunchItem) => (
503 <Button
504 key={`arsenal-${item.key}`}
505 plain
506 hotkey={item.hotkey}
507 label={`${item.glyph} ${item.label}`}
508 onPress={() => press($, item.command, item.args, true)}
509 />
510 )
511
512 return (
513 <Box key="arsenal-menu" flexDirection="column">
514 <Box justifyContent="space-between">
515 <Text bold color={COLORS.accent}>
516 ◆ A R S E N A L
517 </Text>
518 <Text dimColor>{view.header}</Text>
519 </Box>
520 <Text dimColor>{'─'.repeat(inner)}</Text>
521 <Box key="arsenal-settings-section" flexDirection="column">
522 <Text dimColor>{sectionTitle('SETTINGS')}</Text>
523 {hasPromptUpgrade(tools) ? (
524 <Box key="arsenal-auto-rows" flexDirection="column">
525 <Box justifyContent="space-between">
526 <Box>
527 <Text color={auto ? COLORS.teal : undefined} dimColor={!auto}>
528 {auto ? '● ' : '○ '}
529 </Text>
530 <Text bold>Prompt Upgrade</Text>
531 </Box>
532 <Button key="arsenal-auto" plain hotkey="p" onPress={() => press($, 'prompt-upgrade', auto ? 'off' : 'on', false)}>
533 {pill(auto)}
534 </Button>
535 </Box>
536 <Text dimColor>{' upgrade every prompt first'}</Text>
537 </Box>
538 ) : null}
539 <Box key="arsenal-chime-rows" flexDirection="column">
540 <Box justifyContent="space-between">
541 <Box>
542 <Text color={view.chime.isOn ? COLORS.teal : undefined} dimColor={!view.chime.isOn}>
543 {view.chime.isOn ? '● ' : '○ '}
544 </Text>
545 <Text bold>Sound notifications</Text>
546 </Box>
547 <Button key="arsenal-chime-switch" plain hotkey="c" onPress={() => setChime($, { ...view.chime, isOn: !view.chime.isOn })}>
548 {pill(view.chime.isOn)}
549 </Button>
550 </Box>
551 {view.chime.isOn ? (
552 <Box key="arsenal-chime-pickers" flexDirection="column">
553 <Box justifyContent="space-between">
554 <Text>♪ Claude</Text>
555 <Button key="arsenal-chime-sound" plain hotkey="o" onPress={() => cycleSound($)}>
556 <Text dimColor>{`${view.sound || 'none found'} ▸`}</Text>
557 </Button>
558 </Box>
559 <Box justifyContent="space-between">
560 <Text>↗ You</Text>
561 <Button key="arsenal-chime-sent" plain hotkey="m" onPress={() => cycleSent($)}>
562 <Text dimColor>{`${view.sentSound || 'none'} ▸`}</Text>
563 </Button>
564 </Box>
565 </Box>
566 ) : null}
567 </Box>
568 </Box>
569 {launchItems(tools).length > 0 ? (
570 <Box key="arsenal-launch-section" flexDirection="column">
571 <Text dimColor>{sectionTitle('LAUNCH')}</Text>
572 <Box>
573 {columns.map((items, i) => (
574 <Box key={`arsenal-column-${i}`} flexDirection="column" width={columnWidth}>
575 {items.map(launchRow)}
576 </Box>
577 ))}
578 </Box>
579 </Box>
580 ) : null}
581 {ownSection('user', 'USER')}
582 {ownSection('project', 'PROJECT')}
583 <Text dimColor>{view.note || ' '}</Text>
584 <Text color="inactive" dimColor>{versionLine(VERSION)}</Text>
585 </Box>
586 )
587}
588
589// ── The module ───────────────────────────────────────────────────────────
590
591export const register: Register = on => {
592 on('session.start', async ($, e, next) => {
593 try {
594 await $.command.register({
595 name: 'arsenal',
596 description: 'Open or close the Arsenal menu: the tool mods, one press away',
597 immediate: true,
598 })
599 } catch {
600 // the launcher still works without the command
601 }
602 try {
603 // a hot reload leaves an open pane open
604 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
605 await update($, isOpenAtom, () => isOpen)
606 await update($, editingAtom, () => null)
607 await say($, '')
608 } catch {
609 // a menu that stays as it was is still a menu
610 }
611 try {
612 await $.command.register({
613 name: 'chime',
614 description: 'Sound notifications: on, off, test, or pick the sounds for you and Claude',
615 argumentHint: '[on|off|test|sound <name>|sent <name|none>]',
616 immediate: true,
617 })
618 } catch {
619 // the switch in the menu still works without the command
620 }
621 await refreshTools($)
622 await loadOff($)
623 await loadChime($)
624 await ensureSoundsFolder($)
625 await scan($)
626 try {
627 const model = await $.session.model()
628 await update($, runningAtom, r => reconcile(r ?? null, model))
629 } catch {
630 // the header shows nothing until the first request
631 }
632 return next(e)
633 })
634
635 // A tool that registers its command after this plugin started still gets its
636 // rows: the engine's own list is read once, and this keeps it current
637 on('command.register', async ($, e, next) => {
638 const r = await next(e)
639 try {
640 if (e.name === 'prompt-upgrade' || e.name === 'work-report') await update($, toolsAtom, t => ({ ...(t ?? NO_TOOLS), [e.name]: true }))
641 } catch {
642 // the list read at session start stands
643 }
644 return r
645 }).catch(($, e, next) => next(e))
646
647 // Every model request, main loop and subagents. Only the main loop's say what
648 // the header shows, and the request goes down as it came
649 on('turn.step', async function* ($, e, next) {
650 if (!e.agentId) {
651 try {
652 const now = runningFrom(e.model, e.effort)
653 await update($, runningAtom, r => (sameRunning(r ?? null, now) ? (r ?? null) : now))
654 } catch {
655 // the header is only a label
656 }
657 }
658 return yield* next(e)
659 })
660
661 on('command.run', { command: 'arsenal' }, async $ => {
662 await toggleMenu($)
663 return {}
664 }).catch(() => ({ text: 'Arsenal could not do that right now.' }))
665
666 on('command.run', { command: 'chime' }, async ($, e) => {
667 const held = (await read($, chimeAtom)) ?? NO_CHIME
668 const { sounds, os } = await soundsOf($)
669 const names = listOf(sounds).map(s => s.name)
670 const now = chosen(sounds, held.sound)
671 const sentNow = sentChoice(sounds, held.sent)
672 const command = parseChime(e.args)
673 if (command.kind === 'on' || command.kind === 'off') {
674 await setChime($, { ...held, isOn: command.kind === 'on' })
675 return { text: command.kind === 'on' ? 'Sound notifications on.' : 'Sound notifications off. `/chime on` turns them back on.' }
676 }
677 if (command.kind === 'test') {
678 await playPath($, now?.path, os)
679 return { text: now ? `Played "${now.name}".` : 'No sounds found.' }
680 }
681 if (command.kind === 'sound') {
682 const picked = listOf(sounds).find(s => s.name.toLowerCase() === command.name.toLowerCase())
683 if (!picked) return { text: `No sound called "${command.name}". Pick one of: ${names.join(', ')}` }
684 await setChime($, { ...held, sound: picked.name })
685 await playPath($, picked.path, os)
686 return { text: `Sound set to "${picked.name}".` }
687 }
688 if (command.kind === 'sent') {
689 if (command.name === '') {
690 await setChime($, { ...held, sent: '' })
691 return { text: 'Sent sound set to none.' }
692 }
693 const picked = listOf(sounds).find(s => s.name.toLowerCase() === command.name.toLowerCase())
694 if (!picked) return { text: `No sound called "${command.name}". Pick one of: ${names.join(', ')}, or none` }
695 await setChime($, { ...held, sent: picked.name })
696 await playPath($, picked.path, os)
697 return { text: `Sent sound set to "${picked.name}".` }
698 }
699 return { text: command.kind === 'show' ? describeChime(held, now?.name ?? '', sentNow?.name ?? '', names) : CHIME_HELP }
700 }).catch(() => ({ text: 'Chime could not do that right now.' }))
701
702 // The person's own Enter on a prompt. The sound starts as the prompt leaves the
703 // box, before a hook beneath can hold or drop it (Prompt Upgrade holds a prompt
704 // and sends its upgrade later as a plugin's, which would never be heard)
705 on('prompt.submit', async ($, e, next) => {
706 if (shouldPlaySent(e)) quiet(() => playFor($, 'sent'))
707 return next(e)
708 })
709
710 // Claude's sound, for each time it has something for the person. A reply done:
711 // the main loop's turns only, and not one the person stopped. The sound is
712 // started and never waited for
713 on('turn.complete', async ($, e, next) => {
714 const done = await next(e)
715 if (!e.agentId && !e.isAborted) quiet(() => playFor($, 'claude'))
716 return done
717 })
718
719 // The dialog opens while the call runs, so the sound starts as it appears
720 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
721 quiet(() => playFor($, 'claude'))
722 return next(e)
723 })
724
725 // A permission prompt, where the engine raises one
726 on('classic.Notification', async ($, e, next) => {
727 const done = await next(e)
728 if (e.notification_type === 'permission_prompt') quiet(() => playFor($, 'claude'))
729 return done
730 })
731
732 // A close by the person (Esc, the pane's mark) turns the launcher's arrow
733 // back; an unload (hot reload, exit) is not a close they asked for
734 on('ui.close', async ($, e, next) => {
735 const r = await next(e)
736 if (e.id === PANE && e.origin.kind !== 'unload') {
737 try {
738 await update($, isOpenAtom, () => false)
739 await update($, editingAtom, () => null)
740 } catch {
741 // the arrow is only a label
742 }
743 }
744 return r
745 })
746
747 // The launcher sits in the prompt footer (SessionMode), first on the row the
748 // engine's footer draws, which is also where claude-hud's first row starts.
749 // The engine hands its footer over as one opaque node, with the labels other
750 // mods added (waypoints' plan) already in it, so it is wrapped, never
751 // rebuilt from `modes`. A space after the button keeps the two apart
752 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
753 const open = (await read($, isOpenAtom)) === true
754 const footer = await next(e)
755 const { Box, Button, Text } = $.ui.resolve(e)
756 const label = open ? '◆ Arsenal ▴' : '◆ Arsenal ▾'
757 return (
758 <Box key="arsenal-launcher">
759 <Button key="arsenal-toggle" plain label={label} onPress={() => toggleMenu($)}>
760 <Text color="claude">{`[ ${label} ]`}</Text>
761 </Button>
762 <Text> </Text>
763 {footer}
764 </Box>
765 )
766 })
767
768 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
769 const tools = await read($, toolsAtom)
770 const chime = (await read($, chimeAtom)) ?? NO_CHIME
771 // the list is read as the pane draws, so a file dropped in is found
772 let sound = ''
773 let sentSound = ''
774 if (chime.isOn) {
775 try {
776 const { sounds } = await soundsOf($)
777 sound = chosen(sounds, chime.sound)?.name ?? ''
778 sentSound = sentChoice(sounds, chime.sent)?.name ?? ''
779 } catch {
780 // the rows say none found and none
781 }
782 }
783 const view: View = {
784 tools,
785 chime,
786 sound,
787 sentSound,
788 auto: await readAuto($),
789 header: headerNote((await read($, runningAtom)) ?? null),
790 note: (await read($, noteAtom)) ?? '',
791 own: (await read($, ownAtom)) ?? NO_OWN,
792 off: (await read($, offAtom)) ?? NO_OFF,
793 editing: (await read($, editingAtom)) ?? null,
794 projectKey: (await read($, projectKeyAtom)) ?? '',
795 }
796 return drawMenu($, e, Math.max(20, e.props.bodyColumns || PANE_COLUMNS), view)
797 })
798}
799hooks/chime.ts 183 lines1// The sound notifications: which sounds there are, which one is chosen, how a
2// sound is played, and what `/chime` understands. Only text and lists in, text
3// and lists out: the reading of folders and the running of a player are
4// register.tsx's, so all of this is tested without the engine.
5//
6// Sound notifications idea from https://github.com/angomedia/claude-mods.
7// The code is written fresh for the Windows PCs this mod runs on; none of it is
8// copied from there.
9
10import type { ArsenalChime } from '../types'
11
12export type Os = 'windows' | 'mac' | 'linux'
13export type Sound = { name: string; path: string }
14/** The person's own sounds, and the system's that were found on disk. */
15export type Sounds = { personal: Sound[]; system: Sound[] }
16
17/** Off, with the default sound and no sent sound: what anything unexpected in the store means. */
18export const NO_CHIME: ArsenalChime = { isOn: false, sound: '', sent: '' }
19
20export const CHIME_STORE_KEY = 'chime'
21
22/** `Windows_NT` is Windows; Apple's sound folder or a home under `/Users/` is a Mac; anything else is Linux. */
23export function osFrom(windowsEnv: string | undefined, home: string, hasMacSounds = false): Os {
24 if (windowsEnv === 'Windows_NT') return 'windows'
25 return hasMacSounds || home.startsWith('/Users/') ? 'mac' : 'linux'
26}
27
28/** The folder every Mac has Apple's own alert sounds in. */
29export const MAC_SOUNDS_FOLDER = '/System/Library/Sounds'
30
31export const soundsFolder = (home: string) => `${home}/.claude/arsenal/sounds`
32
33const WINDOWS = ['Windows Notify Messaging', 'Windows Notify System Generic', 'Windows Message Nudge', 'Windows Ding', 'Windows Background']
34const MAC = ['Glass', 'Ping', 'Pop', 'Hero', 'Tink']
35
36/** The system's own sounds, in the order they are offered. The caller keeps those found on disk. */
37export function systemCandidates(os: Os): Sound[] {
38 if (os === 'windows') return WINDOWS.map(name => ({ name, path: `C:/Windows/Media/${name}.wav` }))
39 if (os === 'mac') return MAC.map(name => ({ name, path: `/System/Library/Sounds/${name}.aiff` }))
40 return []
41}
42
43const MP3 = /\.mp3$/i
44
45/** The file types of the person's folder, best first: where one name has several, the first listed here is the sound. A Mac's `afplay` plays more than the Windows players do. */
46const FORMATS: Record<Os, readonly string[]> = {
47 windows: ['wav', 'mp3'],
48 linux: ['wav', 'mp3'],
49 mac: ['wav', 'mp3', 'm4a', 'aiff', 'caf'],
50}
51
52/** The README.txt written into a sounds folder arsenal has just made: what goes in it, in plain text. */
53export const soundsReadme = (os: Os) =>
54 [
55 'Arsenal sound notifications: your own sounds',
56 '',
57 `Put sound files here (${FORMATS[os].map(f => `.${f}`).join(', ')}). Each file name, without the extension, becomes a choice`,
58 'for both sounds in the Arsenal menu (Sound notifications) and for /chime sound and /chime sent.',
59 'The folder is read again every time the menu opens, so a file you add shows up without a restart.',
60 '',
61 'Keep them short: a sound is cut off after 15 seconds.',
62 'If one name has several formats, the first format in the list above is the one used.',
63 'Other files, this README.txt included, are ignored.',
64 '',
65 ].join('\n')
66
67/** The files of the person's folder this system plays, by name without the extension; one name in several formats is one sound, in the best format. */
68export function personalSounds(fileNames: readonly string[], folder: string, os: Os): Sound[] {
69 const formats = FORMATS[os]
70 const byName = new Map<string, { sound: Sound; rank: number }>()
71 for (const file of fileNames) {
72 const dot = file.lastIndexOf('.')
73 const rank = dot < 0 ? -1 : formats.indexOf(file.slice(dot + 1).toLowerCase())
74 if (rank < 0) continue
75 const name = file.slice(0, dot)
76 const held = byName.get(name)
77 if (!held || rank < held.rank) byName.set(name, { sound: { name, path: `${folder}/${file}` }, rank })
78 }
79 return [...byName.values()].map(x => x.sound).sort((a, b) => a.name.localeCompare(b.name))
80}
81
82export const listOf = (s: Sounds): Sound[] => [...s.personal, ...s.system]
83
84/** The sound called `name`; one that is gone or never chosen is the first system sound, else the person's first. */
85export const chosen = (s: Sounds, name: string): Sound | undefined => listOf(s).find(x => x.name === name) ?? s.system[0] ?? s.personal[0]
86
87/** The name after the chosen sound in the list, wrapping; empty when there is nothing to pick. */
88export function nextName(s: Sounds, name: string): string {
89 const list = listOf(s)
90 if (list.length === 0) return ''
91 const now = chosen(s, name)
92 const at = now ? list.indexOf(now) : -1
93 return list[(at + 1) % list.length]?.name ?? ''
94}
95
96/** The sent sound: the one named. Empty, or one that is gone, is no sound, unlike Claude's, which falls back to the first. */
97export const sentChoice = (s: Sounds, name: string): Sound | undefined => (name === '' ? undefined : listOf(s).find(x => x.name === name))
98
99/** The name after the sent sound in `none`, then the list, wrapping back to none. */
100export function nextSent(s: Sounds, name: string): string {
101 const names = ['', ...listOf(s).map(x => x.name)]
102 const at = names.indexOf(sentChoice(s, name)?.name ?? '')
103 return names[(at + 1) % names.length] ?? ''
104}
105
106/** What `prompt.submit` knows of a prompt, as far as this needs. */
107export type SubmittedPrompt = { origin: { kind: string }; isMeta?: true; command?: unknown }
108
109/** The swoosh is for the person's own Enter on a prompt: not a slash command, a plugin's or a peer's prompt, or one they never see. */
110export const shouldPlaySent = (p: SubmittedPrompt) => p.origin.kind === 'composer' && !p.isMeta && !p.command
111
112/** The command that plays `path`, or null where nothing can. The path goes inside single quotes, doubled. */
113export function playerFor(path: string, os: Os): string[] | null {
114 if (os === 'windows') {
115 const quoted = path.replace(/'/g, "''")
116 return ['powershell', '-NoProfile', '-NonInteractive', '-Command', MP3.test(path) ? mp3Script(quoted) : `(New-Object System.Media.SoundPlayer '${quoted}').PlaySync()`]
117 }
118 return os === 'mac' ? ['afplay', path] : null
119}
120
121/**
122 * `SoundPlayer` plays only wav, so an mp3 goes through WPF's `MediaPlayer`
123 * (PowerShell 5.1 has it; the script has no dispatcher, so no `MediaEnded`).
124 * It starts at once and then waits for the length to be known, and for what is
125 * left of it, so the process outlives the clip. `quoted` has its `'` doubled.
126 */
127const mp3Script = (quoted: string) =>
128 [
129 'Add-Type -AssemblyName presentationCore',
130 '$p = New-Object System.Windows.Media.MediaPlayer',
131 `$p.Open((New-Object System.Uri('${quoted}')))`,
132 '$p.Play()',
133 '$t = [Diagnostics.Stopwatch]::StartNew()',
134 'while (-not $p.NaturalDuration.HasTimeSpan -and $t.ElapsedMilliseconds -lt 3000) { Start-Sleep -Milliseconds 20 }',
135 'if ($p.NaturalDuration.HasTimeSpan) { Start-Sleep -Milliseconds ([int][Math]::Max(0, $p.NaturalDuration.TimeSpan.TotalMilliseconds - $p.Position.TotalMilliseconds) + 100) }',
136 '$p.Close()',
137 ].join('; ')
138
139// ── The store ────────────────────────────────────────────────────────────
140
141/** What `$.store.get('chime')` gave, read back. Anything unexpected is the chime off. */
142export function chimeFromStore(raw: unknown): ArsenalChime {
143 const held = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
144 return { isOn: held.isOn === true, sound: typeof held.sound === 'string' ? held.sound : '', sent: typeof held.sent === 'string' ? held.sent : '' }
145}
146
147/** The value to save for `chime`. `sent` is left out while it is none, so a 0.4.0 store reads back the same. */
148export const chimeToStore = (c: ArsenalChime) => ({ version: 1 as const, isOn: c.isOn, sound: c.sound, ...(c.sent ? { sent: c.sent } : {}) })
149
150// ── /chime ───────────────────────────────────────────────────────────────
151
152/** `sent`'s name is empty for `none`. */
153export type ChimeCommand = { kind: 'show' | 'on' | 'off' | 'test' | 'help' } | { kind: 'sound' | 'sent'; name: string }
154
155export function parseChime(args: string): ChimeCommand {
156 const text = args.trim()
157 const [word = '', ...rest] = text.split(/\s+/)
158 const lower = word.toLowerCase()
159 if (text === '') return { kind: 'show' }
160 if (lower === 'on' || lower === 'off' || lower === 'test') return { kind: lower }
161 const name = rest.join(' ').trim()
162 if (lower === 'sound' && name !== '') return { kind: 'sound', name }
163 if (lower === 'sent' && name !== '') return { kind: 'sent', name: name.toLowerCase() === 'none' ? '' : name }
164 return { kind: 'help' }
165}
166
167export const CHIME_HELP = [
168 '`/chime on` · `/chime off` · `/chime test`',
169 "`/chime sound <name>`: pick Claude's sound (a reply, a question, a permission prompt)",
170 "`/chime sent <name|none>`: pick the sound for your own Enter",
171].join('\n')
172
173/** What bare `/chime` says: on or off, both sounds shown, and the names to pick from. */
174export const describeChime = (c: ArsenalChime, shownName: string, sentName: string, names: readonly string[]) =>
175 [
176 `Sound notifications are **${c.isOn ? 'on' : 'off'}**.`,
177 `Sound: ${shownName || 'none found'}`,
178 `Sent: ${sentName || 'none'}`,
179 names.length ? `Sounds: ${names.join(', ')}` : 'No sounds found.',
180 '',
181 CHIME_HELP,
182 ].join('\n')
183hooks/menu.ts 256 lines1// What the menu needs that is only arithmetic and wording: the labels in the
2// header, which rows the session has, and how they fall into columns. Nothing
3// here touches the engine, so all of it is tested without one.
4
5import type { ArsenalChime, ArsenalOff, ArsenalOwn, ArsenalRunning, ArsenalTools, OwnCommand } from '../types'
6import { NO_CHIME } from './chime'
7
8/** Menus at least this many cells wide draw the Launch rows in two columns. */
9export const TWO_COLUMNS = 64
10
11export type LaunchItem = {
12 /** Names the button: `arsenal-<key>`. */
13 key: string
14 glyph: string
15 label: string
16 /** The slash command it runs, without the slash: the tool it belongs to. */
17 command: keyof ArsenalTools
18 /** The command's own argument text, as typed after its name. */
19 args: string
20 hotkey: string
21}
22
23// Every row ends in a command the tool already has. The tool's name in
24// `command` is also the key into `tools`, so a missing tool drops its rows
25export const LAUNCH: readonly LaunchItem[] = [
26 { key: 'upgrade', glyph: '✦', label: 'Upgrade a prompt', command: 'prompt-upgrade', args: '', hotkey: 'u' },
27 { key: 'history', glyph: '◷', label: 'Prompt history', command: 'prompt-upgrade', args: 'history', hotkey: 'h' },
28 { key: 'today', glyph: '▤', label: "Today's report", command: 'work-report', args: 'today', hotkey: 't' },
29 { key: 'week', glyph: '▥', label: "This week's report", command: 'work-report', args: 'week', hotkey: 'w' },
30 { key: 'settings', glyph: '⚙', label: 'Report settings', command: 'work-report', args: 'settings', hotkey: 's' },
31]
32
33// ── Header ───────────────────────────────────────────────────────────────
34
35const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
36
37const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
38const version = (major: string, minor?: string) => (minor ? `${major}.${minor}` : major)
39
40/**
41 * `claude-opus-5-5` → `Opus 5.5`, `claude-sonnet-5-5[1m]` → `Sonnet 5.5`, the
42 * alias `opus` → `Opus`. Anything else is shown as given.
43 */
44export function modelLabel(raw: string): string {
45 const given = raw.trim()
46 const id = given.toLowerCase().replace(/\[[^\]]*\]$/, '')
47 // claude-<family>-<major>[-<minor>][-<date>]; a minor is one or two digits,
48 // so a date such as 20250514 is never taken for one
49 const named = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(id)
50 if (named) {
51 const [, family = '', major = '', minor] = named
52 return `${cap(family)} ${version(major, minor)}`
53 }
54 // the older order: claude-<major>[-<minor>]-<family>[-<date>]
55 const older = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)(?:-\d{8})?$/.exec(id)
56 if (older) {
57 const [, major = '', minor, family = ''] = older
58 return `${cap(family)} ${version(major, minor)}`
59 }
60 if (FAMILIES.includes(id)) return cap(id)
61 return given
62}
63
64/** `opus` and `claude-opus-5-5` are both of the family `opus`; null when unknown. */
65function familyOf(raw: string): string | null {
66 const label = modelLabel(raw)
67 const word = (label.split(' ')[0] ?? '').toLowerCase()
68 return FAMILIES.includes(word) ? word : null
69}
70
71const EFFORTS: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max' }
72
73/** `high` → `High`, `32000` → `effort 32000`, absent → an empty string. */
74export function effortLabel(effort: string | number | null | undefined): string {
75 if (effort === null || effort === undefined || effort === '') return ''
76 const text = String(effort)
77 if (/^\d+$/.test(text)) return `effort ${text}`
78 return EFFORTS[text.toLowerCase()] ?? text
79}
80
81/** The header's right side: `Opus 5.5 · High`, the model alone, or nothing. */
82export function headerNote(running: ArsenalRunning | null): string {
83 if (!running) return ''
84 const model = modelLabel(running.model)
85 const effort = effortLabel(running.effort)
86 return effort ? `${model} · ${effort}` : model
87}
88
89/** What a `turn.step` of the main loop says the model and effort are. */
90export function runningFrom(model: string, effort: string | number | undefined): ArsenalRunning {
91 return { model, effort: effort === undefined ? null : String(effort) }
92}
93
94export const sameRunning = (a: ArsenalRunning | null, b: ArsenalRunning | null) =>
95 a === b || (!!a && !!b && a.model === b.model && a.effort === b.effort)
96
97/**
98 * `$.session.model()` as the menu opens: what the person set with `/model`
99 * since the last request. The last request's model and effort stand while it
100 * is the same model (an alias such as `opus` is the family of
101 * `claude-opus-5-5`); otherwise the effort is not known and is left out.
102 */
103export function reconcile(running: ArsenalRunning | null, sessionModel: string): ArsenalRunning | null {
104 const session = sessionModel.trim()
105 if (!session) return running
106 if (running) {
107 if (modelLabel(running.model) === modelLabel(session)) return running
108 const family = familyOf(session)
109 const isAlias = family !== null && session.toLowerCase().replace(/\[[^\]]*\]$/, '') === family
110 if (isAlias && familyOf(running.model) === family) return running
111 }
112 return { model: session, effort: null }
113}
114
115// ── Rows ─────────────────────────────────────────────────────────────────
116
117/** Which tools the session has, from the names `$.command.list()` gives. */
118export function toolsFrom(names: readonly string[]): ArsenalTools {
119 return { 'prompt-upgrade': names.includes('prompt-upgrade'), 'work-report': names.includes('work-report') }
120}
121
122/** Nothing found in either section. */
123export const NO_OWN: ArsenalOwn = { user: [], project: [] }
124
125/** Nothing turned off. */
126export const NO_OFF: ArsenalOff = { user: [], project: {} }
127
128/** The Prompt Upgrade switch needs prompt-upgrade. The chime's rows need nothing, so the menu always has something to show. */
129export const hasPromptUpgrade = (tools: ArsenalTools) => tools['prompt-upgrade']
130
131/**
132 * Lines SETTINGS takes: its title, Prompt Upgrade's switch and description,
133 * the chime's switch, and the Claude and You rows while the chime is on.
134 */
135export const settingsLines = (tools: ArsenalTools, chime: ArsenalChime) => 1 + (hasPromptUpgrade(tools) ? 2 : 0) + 1 + (chime.isOn ? 2 : 0)
136
137/** The Launch rows whose tool is there, in the order they are listed. */
138export const launchItems = (tools: ArsenalTools): LaunchItem[] => LAUNCH.filter(item => tools[item.command])
139
140/**
141 * The Launch rows in columns: prompt-upgrade's on the left and work-report's
142 * on the right when there is room and both tools are there, else one column.
143 */
144export function launchColumns(tools: ArsenalTools, bodyColumns: number): LaunchItem[][] {
145 const items = launchItems(tools)
146 if (bodyColumns >= TWO_COLUMNS && tools['prompt-upgrade'] && tools['work-report']) {
147 return [items.filter(i => i.command === 'prompt-upgrade'), items.filter(i => i.command === 'work-report')]
148 }
149 return items.length ? [items] : []
150}
151
152// ── USER and PROJECT ─────────────────────────────────────────────────────
153
154export type Section = 'user' | 'project'
155
156/** The names turned off for a section; PROJECT's list is the repo's, by key. */
157export const offFor = (off: ArsenalOff, section: Section, projectKey: string): string[] =>
158 section === 'user' ? off.user : (off.project[projectKey] ?? [])
159
160/** `off` with the section's list replaced; an empty project list leaves no key. */
161export function withOff(off: ArsenalOff, section: Section, projectKey: string, names: string[]): ArsenalOff {
162 if (section === 'user') return { ...off, user: names }
163 const { [projectKey]: _gone, ...rest } = off.project
164 return { ...off, project: names.length ? { ...rest, [projectKey]: names } : rest }
165}
166
167/** A name off goes on, and a name on goes off. The list stays sorted. */
168export const flipped = (names: readonly string[], name: string): string[] =>
169 names.includes(name) ? names.filter(n => n !== name) : [...names, name].sort()
170
171/** Edit mode shows every command; otherwise only those not turned off. */
172export const shownRows = (commands: readonly OwnCommand[], off: readonly string[], isEditing: boolean): OwnCommand[] =>
173 isEditing ? [...commands] : commands.filter(c => !off.includes(c.name))
174
175/** `text` cut to `room` cells, ending in `…` where it was cut. */
176export const clipTo = (text: string, room: number) => (room <= 0 ? '' : text.length > room ? `${text.slice(0, room - 1)}…` : text)
177
178/** The cells held back at a row's right end for the `…` button and its gap. */
179export const MORE_CELLS = 4
180
181/** Descriptions shorter than this are left out of a row rather than shown as a stub. */
182const MIN_DETAIL = 6
183
184/** The widest `/name` of a section, but never more than half the menu. */
185export const nameCells = (commands: readonly OwnCommand[], inner: number) =>
186 Math.min(Math.max(0, ...commands.map(c => c.name.length + 1)), Math.floor(inner / 2))
187
188/**
189 * A row's two parts: `/name`, padded so a section's descriptions line up, and
190 * the description, dim and clipped to the room left. The room always holds
191 * back the `…` column, so rows with and without one line up too.
192 */
193export function ownRow(command: OwnCommand, nameWidth: number, inner: number): { label: string; detail: string } {
194 const label = `/${command.name}`.padEnd(nameWidth)
195 const room = inner - label.length - 2 - MORE_CELLS
196 return { label, detail: room >= MIN_DETAIL && command.description ? ` ${clipTo(command.description, room)}` : '' }
197}
198
199/** Lines a section takes: its header, then its rows, or `all off` when none show. */
200export function sectionLines(commands: readonly OwnCommand[], off: readonly string[], isEditing: boolean): number {
201 if (commands.length === 0) return 0
202 return 1 + (isEditing ? commands.length : Math.max(1, shownRows(commands, off, false).length))
203}
204
205/** The pane never asks for fewer rows than 0.1.0 did, which held a little slack. */
206const MIN_PANE_ROWS = 14
207const PANE_SLACK = 2
208
209/**
210 * How many rows the pane asks for when it is placed above the prompt (docked,
211 * the dock's height is used and this is ignored): every line the menu draws.
212 */
213export function paneRows(
214 tools: ArsenalTools,
215 own: ArsenalOwn,
216 off: ArsenalOff,
217 projectKey: string,
218 editing: Section | null,
219 bodyColumns: number,
220 chime: ArsenalChime = NO_CHIME,
221): number {
222 const columns = launchColumns(tools, bodyColumns)
223 const launch = columns.length ? 1 + Math.max(...columns.map(c => c.length)) : 0
224 const sections = (['user', 'project'] as const).reduce(
225 (sum, s) => sum + sectionLines(own[s], offFor(off, s, projectKey), editing === s),
226 0,
227 )
228 // the header's 2, the settings, the launch rows, the sections, the note and the version row
229 const lines = 2 + settingsLines(tools, chime) + launch + sections + 1 + 1
230 return Math.max(MIN_PANE_ROWS, lines + PANE_SLACK)
231}
232
233/** The pane's last row: the mod and its version. */
234export const versionLine = (version: string) => `arsenal v${version}`
235
236// ── Words ────────────────────────────────────────────────────────────────
237
238/** Said when `…` finds a draft in the box: it is never written over. */
239export const draftNote = (name: string) => `Clear the prompt box to fill /${name}.`
240export const unfilledNote = 'Could not fill the prompt box.'
241
242/** `SETTINGS` → `── S E T T I N G S`. */
243export const sectionTitle = (name: string) => `── ${[...name].join(' ')}`
244
245/** `/prompt-upgrade`, `/work-report today`: what a press runs, as the person would type it. */
246export const commandLine = (command: string, args: string) => `/${command}${args ? ` ${args}` : ''}`
247
248const NOTE_LIMIT = 80
249
250const clip = (text: string) => (text.length > NOTE_LIMIT ? `${text.slice(0, NOTE_LIMIT - 1)}…` : text)
251
252/** Said while the switch's command is on its way: run now, or once a turn ends. */
253export const sentNote = (line: string) => clip(`sent: ${line}`)
254export const ranNote = (line: string) => clip(`ran: ${line}`)
255export const failedNote = (line: string, why: string) => clip(`${line} failed: ${why}`)
256hooks/version.ts 6 lines1// The host gives a hooks module no way to read its own manifest (not through
2// "node:fs", not as a JSON import), so the pane's last row carries a copy.
3// Bump VERSION with plugin.json on every release; CI fails a pull request
4// where the two differ (.github/scripts/check-versions.mjs).
5export const VERSION = '0.4.6'
6hooks/own.ts 162 lines1// The person's own skills and commands: how a file becomes a row, and where
2// the project is. Only text in, text out: the reading of folders and the git
3// calls are register.tsx's, so all of this is tested without the engine.
4
5import type { ArsenalOff, OwnCommand } from '../types'
6
7// ── Frontmatter ──────────────────────────────────────────────────────────
8
9const unquote = (value: string) => {
10 const first = value.charAt(0)
11 return value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first) ? value.slice(1, -1) : value
12}
13
14/**
15 * The `key: value` lines between a leading `---` and the next one, the value
16 * with its surrounding quotes taken off. A line that is indented (the rest of
17 * a multi-line value) or has no key is ignored; no block, or one never closed,
18 * is no keys at all.
19 */
20export function parseFrontmatter(text: string): Record<string, string> {
21 const lines = text.replace(/^/, '').split(/\r?\n/)
22 if (lines[0]?.trim() !== '---') return {}
23 const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
24 if (end < 0) return {}
25 const keys: Record<string, string> = {}
26 for (const line of lines.slice(1, end)) {
27 const match = /^([A-Za-z][\w-]*):(.*)$/.exec(line)
28 if (match) keys[match[1] ?? ''] = unquote((match[2] ?? '').trim())
29 }
30 return keys
31}
32
33// ── Naming and classification ────────────────────────────────────────────
34
35/** A file in `skills/<folder>/SKILL.md` or `commands/<file>.md`, as read. */
36export type Candidate = {
37 kind: 'skill' | 'command'
38 /** The folder's name for a skill, the file's name without `.md` for a command. */
39 fallback: string
40 text: string
41}
42
43/** What a file says about itself; the engine says whether it is really there. */
44export type Found = { name: string; takesArgs: boolean }
45
46/**
47 * A skill is named by its frontmatter `name`, else its folder; a command by
48 * its file. `user-invocable: false` is no command for the person, so null.
49 */
50export function readCandidate(c: Candidate): Found | null {
51 const keys = parseFrontmatter(c.text)
52 if ((keys['user-invocable'] ?? '').toLowerCase() === 'false') return null
53 const name = c.kind === 'skill' ? keys.name || c.fallback : c.fallback
54 return { name, takesArgs: (keys['argument-hint'] ?? '') !== '' }
55}
56
57// ── Confirming against the engine ────────────────────────────────────────
58
59/** What `$.command.list()` gives for each command that matters here. */
60export type Listed = { name: string; description: string; source: string }
61
62const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
63
64/**
65 * The rows for what a folder scan found: only the names the engine lists as
66 * the person's own (`source: 'user'`; a plugin's skill of the same name is
67 * not), with the description it gives. The synced claude.ai skills are `user`
68 * too, which is why the folders decide first and the engine confirms after.
69 * A skill and a command of one name make one row; either may take arguments.
70 */
71export function confirmed(found: readonly Found[], list: readonly Listed[]): OwnCommand[] {
72 const own = new Map<string, string>()
73 for (const c of list) if (c.source === 'user' && !own.has(c.name)) own.set(c.name, oneLine(c.description))
74 const rows = new Map<string, OwnCommand>()
75 for (const f of found) {
76 const description = own.get(f.name)
77 if (description === undefined) continue
78 rows.set(f.name, { name: f.name, description, takesArgs: (rows.get(f.name)?.takesArgs ?? false) || f.takesArgs })
79 }
80 return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name))
81}
82
83/**
84 * The project's rows without the names USER has. The global one is the one
85 * `/name` runs (checked), so such a name is drawn once, under USER.
86 */
87export function withoutUser(project: readonly OwnCommand[], user: readonly OwnCommand[]): OwnCommand[] {
88 const taken = new Set(user.map(c => c.name))
89 return project.filter(c => !taken.has(c.name))
90}
91
92// ── What is saved ────────────────────────────────────────────────────────
93
94/** `$.store` key `off` holds this: names turned off, never on, so new commands show by themselves. */
95export const STORE_KEY = 'off'
96
97const names = (value: unknown): string[] =>
98 Array.isArray(value) ? [...new Set(value.filter((v): v is string => typeof v === 'string' && v !== ''))].sort() : []
99
100/**
101 * What `$.store.get('off')` gave, read back. Anything unexpected (unset, an
102 * older or hand-edited file) is an empty list for that part, never an error.
103 */
104export function offFromStore(raw: unknown): ArsenalOff {
105 const held = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
106 const project: Record<string, string[]> = {}
107 if (held.project && typeof held.project === 'object' && !Array.isArray(held.project)) {
108 for (const [key, list] of Object.entries(held.project)) {
109 const kept = names(list)
110 if (kept.length) project[key] = kept
111 }
112 }
113 return { user: names(held.user), project }
114}
115
116/** The value to save for `off`. */
117export const offToStore = (off: ArsenalOff) => ({ version: 1, user: off.user, project: off.project })
118
119// ── Where the project is ─────────────────────────────────────────────────
120
121/**
122 * Slashes `/`, no trailing slash, and the case folded on a drive path: the
123 * form two spellings of one folder share. A POSIX path keeps its case.
124 */
125export function normalizePath(path: string): string {
126 const slashed = path.replace(/\\/g, '/').replace(/\/+$/, '')
127 return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed
128}
129
130export const sameFolder = (a: string, b: string) => normalizePath(a) === normalizePath(b)
131
132const isAbsolute = (path: string) => /^([A-Za-z]:|\/|\\\\)/.test(path)
133
134/** `path` against `base`, with the `.` and `..` steps taken. */
135function resolveFrom(base: string, path: string): string {
136 if (isAbsolute(path)) return path
137 const parts = base.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
138 for (const step of path.replace(/\\/g, '/').split('/')) {
139 if (step === '..') {
140 if (parts.length > 1) parts.pop()
141 } else if (step !== '.' && step !== '') {
142 parts.push(step)
143 }
144 }
145 return parts.join('/')
146}
147
148/** What the two `git rev-parse` calls printed; null where git did not answer. */
149export type GitAnswers = { top: string | null; common: string | null }
150
151/**
152 * The folder to scan for PROJECT and the key of its off list. In a repo they
153 * are this checkout's top level and the repo's shared `.git` folder, so a
154 * worktree or a subfolder of one repo keeps one off list. A relative `.git`
155 * (a git older than the `--path-format` flag) is read against the folder git
156 * ran in, `root`. Outside git, `root` is both.
157 */
158export function projectPlace(root: string, git: GitAnswers): { folder: string; key: string } {
159 if (!git.top || !git.common) return { folder: root, key: normalizePath(root) }
160 return { folder: git.top, key: normalizePath(resolveFrom(root, git.common)) }
161}
162types/index.d.ts 46 lines1/** The model and effort the main loop last ran, as the header shows them. */
2export type ArsenalRunning = {
3 /** As the engine gave it: a resolved id (`claude-opus-5-5`) or `/model`'s alias (`opus`). */
4 model: string
5 /** `low` … `max`, a number written out, or null for a model without effort. */
6 effort: string | null
7}
8
9/** Which tools this session has, from $.command.list() at session start. */
10export type ArsenalTools = { 'prompt-upgrade': boolean; 'work-report': boolean }
11
12/** One of the person's own commands, as a row shows it. */
13export type OwnCommand = { name: string; description: string; takesArgs: boolean }
14
15/** What the last scan found, by section, each sorted by name. */
16export type ArsenalOwn = { user: OwnCommand[]; project: OwnCommand[] }
17
18/** The names turned off in edit mode. `project` is keyed by the project key (the repo's shared `.git` folder). */
19export type ArsenalOff = { user: string[]; project: Record<string, string[]> }
20
21/**
22 * The sound notifications' settings. `sound` is Claude's (a reply, a question, a
23 * permission prompt) and `sent` the person's own Enter; each is a name from the
24 * list. Empty `sound` means the default, empty `sent` means no sound.
25 */
26export type ArsenalChime = { isOn: boolean; sound: string; sent: string }
27
28declare module 'claude-code' {
29 interface PluginState {
30 arsenal: {
31 isOpen: boolean
32 running: ArsenalRunning | null
33 tools: ArsenalTools
34 /** One line under the menu: what the switch sent or ran, or why a press failed. */
35 note: string
36 own: ArsenalOwn
37 off: ArsenalOff
38 /** The section in edit mode, if any. */
39 editing: 'user' | 'project' | null
40 /** The key into `off.project` for this session's repo, from the last scan. */
41 projectKey: string
42 chime: ArsenalChime
43 }
44 }
45}
46