SLOPSHOPPER

arsenal

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…

panespinnerguardcommandtoastprompt
v0.4.6MITupdated 2026-10-25afterever/claude-mods/arsenal
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · arsenal
│ ┃ Arsenal ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ A R S E N A L Opus 5.5 │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ── S E T T I N G S ⏺ Update(src/auth.ts) │ ┃ ○ Sound notificationsc: arsenal-chime-switch ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ arsenal v0.4.6 ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /arsenal │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◆ Arsenal ▴ ⟨Claude Code's own drawing⟩

Draws

Pane · Arsenal
◆ A R S E N A L Opus 5.5 ──────────────────────────────────────────────────────── ── S E T T I N G S ○ Sound notifications c: arsenal-chime-switch arsenal v0.4.6
README

arsenal

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…
└──────────────────────────────────────

Use it

DoResult
Press [ ◆ Arsenal ▾ ], in the footer on the first row under the prompt boxOpens the menu pane, docked on the right in fullscreen (above the prompt otherwise). The button turns to ▴; press it again to close.
/arsenalThe same toggle from the keyboard. It works while Claude is busy.
Esc, or the pane's close markCloses it, and the button turns back to ▾.

The rows

RowRuns
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.

  • A Launch row closes the Arsenal pane, then runs its command, so the tool's own pane takes its place. During a turn, the engine runs the command once Claude is idle.
  • The switch leaves the pane open so you can watch the pill change. The line at the bottom says sent: /prompt-upgrade on, then ran: /prompt-upgrade on.
  • A failure shows on that bottom line, or as a toast if the pane had already closed.
  • Two columns from 64 cells: drag the dock wider and the work-report rows move to a column of their own.
  • The header shows the model and effort of the last request (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.

Which rows show

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.

Sound notifications

Off until you turn them on: press the switch (c), or /chime on. There are two sounds, as in iMessage:

  • You (the ↗ You row): your own Enter on a prompt plays your sound, the swoosh. It is 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.
  • Claude (the ♪ Claude row): its sound plays when a reply is done (only Claude's own turn, not a subagent's, and not one you stopped), when Claude asks you something (a question dialog) and when it needs a permission OK.

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.

CommandDoes
/chimeSays whether the sounds are on, which sound each row has, and the names to pick from.
/chime on, /chime offThe 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 nonePicks the sound for your own Enter (the ↗ You row) and plays it, or turns it off.
/chime testPlays 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>).

Your own commands

Under LAUNCH, two sections list what you wrote yourself:

  • USER is ~/.claude/skills/<name>/SKILL.md and ~/.claude/commands/<name>.md.
  • PROJECT is the same two folders in the current repo (its top level, from any subfolder or worktree).

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.

DoResult
Press a rowCloses the pane and runs the command bare, as if you had typed /name.
Press the … at the end of a rowCloses 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 headerShows 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.

What it does not do

  • It is a remote control, with one thing of its own: the sound notifications. Every other row ends in a slash command that already exists. arsenal never writes another mod's state or your skill and command files, and never imports another mod's code. If you uninstall a tool, its rows disappear.
  • It leaves the status mods alone. 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.
  • It has no model or effort picker. There is no "auto model" and no helper-agent model setting.
  • It keeps two things between sessions: which of your own commands you turned off, and the sound notifications' switch and two sounds. A new session starts with the menu closed.
  • It makes no model calls and sends nothing anywhere.

Install

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.

Develop

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".

Source 6 files
hooks/register.tsx 799 lines
1import { 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}
799
hooks/chime.ts 183 lines
1// 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')
183
hooks/menu.ts 256 lines
1// 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}`)
256
hooks/version.ts 6 lines
1// 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'
6
hooks/own.ts 162 lines
1// 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}
162
types/index.d.ts 46 lines
1/** 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