SLOPSHOPPER

mod-hub

A bar above the prompt with your pinned commands and a prompt-cache countdown, a /mod-manager pane to turn mods on and off, run them and save presets of your…

newpanebandguardcommandtool
v0.2.1MITupdated 2026-10-23THEROCKSSS/claude-mods/mods/mod-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-hub
│ ┃ Mod Manager ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Mod Manager [ ▣ Mods ] ★ Presets [ ◈ │ ┃ 0 installed · 0 on ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ │ Search mods and commands… ⏎ search ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ [ All 0 ] ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ Sort: On first ▾ ▤ Compact [ Click: ▶ Ru ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────────────────────── │ ┃ │ the hub did not answer on port 8796; is D › /mod-manager │ ┃ │ something else using it? ⎿ mod-hub: Mod Manager opened. │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ NOTHING SHOWN │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ │ ┃ │ No mod is every installed mod. │ │ ┃ │ Show all mods │ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▣ mods │ [ ⚙ Manage ] [ ◈ Hub ] │ ◷ cache · no request yet │ ☆ pin commands in Manage to keep them here │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ▣ mods │ [ ⚙ Manage ] [ ◈ Hub ] │ ◷ cache · no request yet │ ☆ pin commands in Manage to keep them ╰─────────────────────────────────────────────────────────────────────────────────────────────────── ⟨Claude Code's own drawing⟩
Pane · Mod Manager
▣ Mod Manager [ ▣ Mods ] ★ Presets [ ◈ Start Hub ] ✕ 0 installed · 0 on ╭──────────────────────────────────────────────────────────╮ │ Search mods and commands… ⏎ search │ │ │ │ [ All 0 ] │ │ │ │ Sort: On first ▾ ▤ Compact [ Click: ▶ Run ] │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ the hub did not answer on port 8796; is something else Di │ using it? ╰─────────────────────────────────────────────────────────── NOTHING SHOWN ╭──────────────────────────────────────────────────────────╮ │ │ │ No mod is every installed mod. │ │ Show all mods │ │ │ ╰──────────────────────────────────────────────────────────╯
README

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

mod-hub

The mod manager. You get three things:

  • a bar above the prompt
  • a /mod-manager pane
  • /hub, a live web control room on 127.0.0.1:8796

The Mod Hub web app

What it does

  • The bar holds ⚙ Mods, ◈ Hub, your pinned commands and a prompt-cache countdown ("◷ cache warm · 3:42 left"). The countdown learns whether your cache lasts 5 minutes or an hour.
  • /mod-manager lets you:
  • turn any mod on or off
  • run or copy its commands, and pin them to the bar
  • filter by on, off, pinned or new, and search

It lists itself first, marked.

  • Presets are named setups: which mods are on, which commands are pinned, and commands to run. Apply one in a press, or snapshot what you have now. Claude can save presets for you through its mod_presets tool, but only you can apply one.
  • /hub opens the web app. It has four views:
  • Mods: a 3D constellation of your mods, plus the same controls as the pane
  • Presets
  • Logic graph: needs mod-logic
  • Mission: needs mission-control-win

Ctrl K opens a command palette.

  • The mod-setup skill teaches an agent to install, check and add mods in this marketplace.

Settings

SettingDefault
port8796The local port the web app listens on.
showBartrueOff hides the bar; /mod-manager and /hub still work.
cacheTtlauto5m or 1h fixes the countdown's lifetime instead of learning it.

How it stays safe

The page never runs anything itself. It queues one of a fixed set of actions, and the mod checks each one against what this session has installed. The server listens on 127.0.0.1 only and needs a token only the served page knows. It refuses foreign Host and Origin headers. See SECURITY.md.

The server writes ~/.claude/mod-hub/hub.log, so a hub that stopped says why. A hub that disappears is started again, at most once a minute.

Develop

node --test server/hub.test.mjs
cd web && npm ci && npm run build        # writes ../app, which is committed
npm run build:demo                       # the NON-LIVE DATA Pages demo, into web/dist-demo
Source 5 files
hooks/register.tsx 1258 lines
1// Mod Hub: every installed mod in one place, without typing commands in chat.
2//   - A bar above the prompt: ⚙ Mods opens the manager pane, ◈ Hub the web app, then a
3//     button for each pinned command, and ↻ Reload once a mod was turned on or off.
4//   - /mod-manager: a pane listing each mod with On/Off, a button per command, and pins.
5//   - /hub: starts the local web app (server/hub.mjs) and opens it in the browser.
6//   - Presets: named setups (mods on or off, pins, commands to run), saved and applied from
7//     the pane or the hub. Claude can make them through the mod_presets tool; only the person applies.
8//   - The bar shows how long the prompt cache stays warm after the last request.
9//     The page only queues actions; this module checks each one against what is
10//     installed and runs it: a command through $.command.run, On/Off through the
11//     running Claude Code's `plugin enable|disable`, a reload through /reload-plugins.
12import { atom, read, update } from 'claude-code'
13import type { CommandInfo, EngineInterface, Register, Timer } from 'claude-code'
14
15import type { HubState, ModRow } from '../types'
16import { bandsFile, parseOrder, stackBands } from './bands'
17import { cleanView, counts, DEFAULT_VIEW, FILTERS, isDefault, isNew, SORTS, sources, viewMods, type View } from '../shared/view'
18import { cleanPreset, countdown, isToggle, plan, snapshot, summary, upsert, type Preset, type RecentCommand } from '../shared/presets'
19
20const NAME = 'mod-hub'
21const PANE = 'mod-hub'
22const PER_PAGE = { cards: 6, compact: 12 }
23const VIEW_KEY = 'view'
24const TICK_MS = 1_500 // how often the hub is asked for actions while it runs
25const REFRESH_MS = 15_000 // how often installed plugins are read again
26const PINS_KEY = 'pins'
27const DEFAULT_PINS = ['mod-logic', 'mission']
28const SERVER_VERSION = 3 // server/hub.mjs SERVER_VERSION: a running hub below this is replaced
29const PRESETS_KEY = 'presets'
30const APPLY_KEY = 'pendingApply' // a preset whose commands wait for the reload its mods asked for
31const TTL_KEY = 'cacheTtl'
32const MIN5 = 5 * 60_000
33const HOUR = 60 * 60_000
34const MAX_RECENT = 12
35
36const state = atom({ plugin: 'mod-hub', key: 'state' } as const, null as HubState | null)
37const hubUrl = atom({ plugin: 'mod-hub', key: 'hubUrl' } as const, '')
38const note = atom({ plugin: 'mod-hub', key: 'note' } as const, '')
39const view = atom({ plugin: 'mod-hub', key: 'view' } as const, DEFAULT_VIEW as View)
40const compact = atom({ plugin: 'mod-hub', key: 'compact' } as const, false)
41const clickMode = atom({ plugin: 'mod-hub', key: 'clickMode' } as const, 'run' as 'run' | 'copy')
42const page = atom({ plugin: 'mod-hub', key: 'page' } as const, 0)
43const busy = atom({ plugin: 'mod-hub', key: 'busy' } as const, '')
44const tab = atom({ plugin: 'mod-hub', key: 'tab' } as const, 'mods' as 'mods' | 'presets')
45const editing = atom({ plugin: 'mod-hub', key: 'editing' } as const, '')
46const confirmDelete = atom({ plugin: 'mod-hub', key: 'confirmDelete' } as const, '')
47const draftName = atom({ plugin: 'mod-hub', key: 'draftName' } as const, '')
48const recentAtom = atom({ plugin: 'mod-hub', key: 'recent' } as const, [] as RecentCommand[])
49const cacheAt = atom({ plugin: 'mod-hub', key: 'cacheAt' } as const, 0)
50const beat = atom({ plugin: 'mod-hub', key: 'beat' } as const, 0)
51
52type Server = { port: number; host: string; token: string; pid: number }
53type Action =
54  | { seq: number; type: 'run'; command: string; args?: string }
55  | { seq: number; type: 'toggle'; id: string; on: boolean }
56  | { seq: number; type: 'reload' }
57  | { seq: number; type: 'pins'; pins: string[] }
58  | { seq: number; type: 'preset-save'; preset: unknown }
59  | { seq: number; type: 'preset-delete'; id: string }
60  | { seq: number; type: 'preset-apply'; id: string }
61  | { seq: number; type: 'preset-snapshot'; name: string }
62
63let port = 8796
64let showBar = true
65let timer: Timer | undefined
66let ticks = 0
67let lastSeq = 0
68let lastPushed = ''
69let isTurnRunning = false // a command run now waits for the turn to end; the person is told so
70let pending: string[] = [] // module-level: a reload runs register again and empties it, which is right
71let bandOrder: Promise<string[]> | undefined
72let ttlMs = MIN5 // the cache lifetime: 5 min until a request shows it lasts an hour
73let isTtlLearned = false
74let ttlOverride: number | null = null
75let beatTimer: Timer | undefined
76let wasHubUp = false // only a hub that was up and went away is restarted on its own
77let lastRestartAt = 0
78
79export const register: Register = (on, options) => {
80  port = typeof options.port === 'number' && options.port >= 1024 ? options.port : 8796
81  showBar = options.showBar !== false
82  ttlOverride = options.cacheTtl === '5m' ? MIN5 : options.cacheTtl === '1h' ? HOUR : null
83  pending = []
84  lastPushed = ''
85  bandOrder = undefined
86
87  on('session.start', async ($, e, next) => {
88    const r = await next(e)
89    await $.command.register({ name: 'mod-manager', description: 'Turn mods on and off, run their commands, and pin them to the bar' }).catch(() => {})
90    await $.command.register({ name: 'hub', description: 'Open the Mod Hub web app: every mod, live, with a command palette' }).catch(() => {})
91    timer?.cancel()
92    timer = $.clock.every(TICK_MS, () => {
93      void tick($).catch(() => {})
94    })
95    await refresh($).catch(() => {})
96    await restoreView($).catch(() => {})
97    await restoreCache($).catch(() => {})
98    await $.tool.register({
99      name: 'mod_presets',
100      description:
101        "Make, change, list or delete the person's mod presets (mod-hub). A preset is a named setup: mods to turn on or off " +
102        "(only the mods it names change), the bar's pinned commands, and slash commands to run when applied. Use action " +
103        "'list' first: it shows the installed mods, their commands (toggles marked), the commands the person ran lately and " +
104        "the presets so far. 'save' creates one (no id) or updates one (its id). You only save presets: the person applies " +
105        'them from the Mod Manager or the hub. Leave commands marked as toggles off (enabled: false) unless asked.',
106      inputSchema: {
107        type: 'object',
108        properties: {
109          action: { type: 'string', enum: ['list', 'save', 'delete'] },
110          id: { type: 'string', description: 'For delete: the preset id' },
111          preset: {
112            type: 'object',
113            description: 'For save. Give id to update an existing preset.',
114            properties: {
115              id: { type: 'string' },
116              name: { type: 'string' },
117              note: { type: 'string', description: 'One line on what it is for' },
118              mods: { type: 'object', description: 'plugin id (name@marketplace) to "on" or "off"', additionalProperties: { type: 'string', enum: ['on', 'off'] } },
119              steps: { type: 'array', items: { type: 'object', properties: { command: { type: 'string' }, args: { type: 'string' }, enabled: { type: 'boolean' } }, required: ['command'] } },
120              pins: { type: 'array', items: { type: 'string' }, description: 'Command names for the bar; leave out to keep the pins as they are' },
121            },
122            required: ['name'],
123          },
124        },
125        required: ['action'],
126      },
127    }).catch(() => {})
128    $.clock.after(1_500, () => void resumeApply($).catch(() => {})) // after the other mods have registered their commands
129    void connect($).catch(() => {}) // a hub left running from an earlier session picks up again
130    return r
131  })
132
133  on('command.run', { command: 'mod-manager' }, async $ => {
134    await refresh($).catch(() => {})
135    await $.ui.open({ id: PANE, title: 'Mod Manager', focus: true })
136    return { text: 'Mod Manager opened.' }
137  })
138
139  on('turn.start', async ($, e, next) => {
140    isTurnRunning = true
141    return next(e)
142  })
143  on('turn.complete', async ($, e, next) => {
144    if (!e.agentId) isTurnRunning = false // a subagent's turn ending leaves the main one running
145    return next(e)
146  })
147
148  on('command.run', { command: 'hub' }, async $ => {
149    const url = await openHub($)
150    return { text: url ? `Mod Hub: ${url}` : `Mod Hub did not start: ${await read($, note)}` }
151  })
152
153  // Every command the person runs, remembered for a preset's "save current setup".
154  on('command.run', async ($, e, next) => {
155    const r = await next(e)
156    if (e.origin.kind !== 'plugin') await remember($, e.command, e.args ?? '').catch(() => {})
157    return r
158  })
159
160  // Each main-thread model request restarts the prompt cache's clock.
161  on('turn.step', async function* ($, e, next) {
162    const result = yield* next(e)
163    if (!e.agentId && result.usage) await sawRequest($, result.usage.cache_read_input_tokens).catch(() => {})
164    return result
165  })
166
167  on('tool.call', { tool: 'mcp__mod-hub__mod_presets' }, async ($, e) => presetTool($, e as unknown as Record<string, unknown>))
168
169  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
170
171  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
172    const rest = await next(e)
173    if (!showBar || e.props.hasSurvey) return rest
174    const s = await read($, state)
175    const { Box, Button, Text } = $.ui.resolve(e)
176    const runnable = new Set(allCommands(s).map(c => c.name))
177    const pins = (s?.pins ?? []).filter(p => runnable.has(p))
178    const cache = await cacheLine($)
179    // Its own panel, bordered like context-bar's, so the buttons read as one control and not as loose text.
180    const mine = (
181      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
182        <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
183          <Text color="#e3a857" bold>
184            {'▣ mods'}
185          </Text>
186          <Text dimColor>│</Text>
187          <Button key="mods" label="⚙ Manage" onPress={() => openPane($)} />
188          <Button key="hub" label="◈ Hub" onPress={() => void openHub($)} />
189          {pending.length > 0 && (
190            <Button key="reload" label={`↻ Reload · ${pending.length} changed`} variant="primary" onPress={() => reload($)} />
191          )}
192          <Text dimColor>│</Text>
193          <Text color={cache.color} dimColor={cache.isDim}>
194            {cache.text}
195          </Text>
196          <Text dimColor>│</Text>
197          {pins.length > 0 ? <Text dimColor>pinned</Text> : <Text dimColor>☆ pin commands in Manage to keep them here</Text>}
198          {pins.map(p => (
199            <Button key={`pin-${p}`} label={`/${p}`} plain onPress={() => pressCommand($, p)} />
200          ))}
201        </Box>
202      </Box>
203    )
204    return stackBands(rest, NAME, mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => [])))
205  })
206}
207
208// ── Reading what is installed ─────────────────────────────────────────────
209
210async function refresh($: EngineInterface) {
211  const [commands, installed, enabled, version, stored] = await Promise.all([
212    $.command.list().catch((): CommandInfo[] => []),
213    readInstalled($),
214    $.settings.read({ key: 'enabledPlugins' }).then(v => (v && typeof v === 'object' ? (v as Record<string, unknown>) : {}), () => ({}) as Record<string, unknown>),
215    $.session.version().then(v => v.version, () => undefined),
216    $.store.get(PINS_KEY).catch(() => undefined),
217  ])
218  const pins = Array.isArray(stored) ? stored.filter((p): p is string => typeof p === 'string') : DEFAULT_PINS
219  const presets = await loadPresets($)
220  const recent = await read($, recentAtom)
221  const at = await read($, cacheAt)
222  await update($, state, () => ({ ...build(commands, installed, enabled, pins, pending, version), presets, recent, cache: at ? { at, ttlMs, isLearned: isTtlLearned } : null }))
223}
224
225type Installed = { id: string; name: string; marketplace: string; version: string; installedAt: string; description: string }
226
227// Every plugin in a commands list and a settings snapshot, as rows the bar, pane and hub draw.
228export function build(
229  commands: CommandInfo[],
230  installed: Installed[],
231  enabled: Record<string, unknown>,
232  pins: string[],
233  pendingIds: string[],
234  claudeVersion?: string,
235): HubState {
236  const byPlugin = new Map<string, { name: string; description: string }[]>()
237  const otherCommands: HubState['otherCommands'] = []
238  for (const c of commands) {
239    if (c.source === 'plugin') {
240      const key = (c.plugin ?? '').replace(/@.*$/, '')
241      byPlugin.set(key, [...(byPlugin.get(key) ?? []), { name: c.name, description: c.description }])
242    } else {
243      otherCommands.push({ name: c.name, description: c.description, source: c.source })
244    }
245  }
246  const mods: ModRow[] = installed
247    .map(p => ({
248      ...p,
249      enabled: enabled[p.id] !== false, // installed and not switched off
250      commands: (byPlugin.get(p.name) ?? []).sort((a, b) => a.name.localeCompare(b.name)),
251      isSelf: p.name === NAME,
252    }))
253    .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name))
254  return { generatedAt: Date.now(), claudeVersion, mods, otherCommands, pins, pending: [...pendingIds], presets: [], recent: [], cache: null }
255}
256
257function allCommands(s: HubState | null) {
258  return [...(s?.mods.flatMap(m => m.commands) ?? []), ...(s?.otherCommands ?? [])]
259}
260
261async function configDir($: EngineInterface): Promise<string> {
262  const config = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
263  if (config) return config.replace(/\\/g, '/').replace(/\/+$/, '')
264  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
265  return `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude`
266}
267
268async function readInstalled($: EngineInterface): Promise<Installed[]> {
269  const text = await $.fs.read(`${await configDir($)}/plugins/installed_plugins.json`).catch(() => '')
270  let plugins: Record<string, unknown> = {}
271  try {
272    plugins = (JSON.parse(typeof text === 'string' ? text : '') as { plugins?: Record<string, unknown> }).plugins ?? {}
273  } catch {
274    return []
275  }
276  const out: Installed[] = []
277  for (const [id, rows] of Object.entries(plugins)) {
278    const row = (Array.isArray(rows) ? rows[0] : undefined) as Record<string, unknown> | undefined
279    if (!row) continue
280    const at = id.lastIndexOf('@')
281    const path = typeof row.installPath === 'string' ? row.installPath.replace(/\\/g, '/') : ''
282    const manifest = path ? await $.fs.read(`${path}/.claude-plugin/plugin.json`).catch(() => '') : ''
283    let description = ''
284    try {
285      const d = (JSON.parse(typeof manifest === 'string' ? manifest : '') as { description?: unknown }).description
286      description = typeof d === 'string' ? d : ''
287    } catch {}
288    out.push({
289      id,
290      name: at > 0 ? id.slice(0, at) : id,
291      marketplace: at > 0 ? id.slice(at + 1) : '',
292      version: String(row.version ?? ''),
293      installedAt: String(row.installedAt ?? ''),
294      description,
295    })
296  }
297  return out
298}
299
300async function readBandOrder($: EngineInterface): Promise<string[]> {
301  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined))?.replace(/\\/g, '/')
302  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), home)
303  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
304}
305
306// ── Doing things ──────────────────────────────────────────────────────────
307
308async function openPane($: EngineInterface) {
309  await refresh($).catch(() => {})
310  await $.ui.open({ id: PANE, title: 'Mod Manager', focus: true })
311}
312
313// Runs a slash command the session lists, as if the person typed it.
314async function runCommand($: EngineInterface, command: string, args = '') {
315  const known = (await $.command.list().catch((): CommandInfo[] => [])).some(c => c.name === command)
316  if (!known) {
317    await update($, note, () => `/${command} is not a command this session has`)
318    return
319  }
320  // The engine runs a command started mid-turn once the turn ends: say so, so the press is not a mystery.
321  if (isTurnRunning) $.ui.toast(`/${command} is queued: it runs as soon as Claude finishes this turn`)
322  if (command !== 'reload-plugins') await remember($, command, args).catch(() => {})
323  await $.command.run({ command, args }).catch(async (err: unknown) => {
324    await update($, note, () => `/${command} did not run: ${err instanceof Error ? err.message : String(err)}`)
325  })
326}
327
328// Puts `/command` on the clipboard: the surface's own first, else the system's (clip, pbcopy, xclip).
329async function copyCommand($: EngineInterface, command: string) {
330  const text = `/${command}`
331  const viaSurface = await $.ui.copy({ text }).catch(() => ({ isCopied: false }))
332  let isCopied = viaSurface.isCopied
333  if (!isCopied) {
334    const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
335    const tools = isWindows ? [['clip']] : [['pbcopy'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]
336    for (const argv of tools) {
337      const r = await $.process.run(argv, { stdin: text, timeoutMs: 5_000 }).catch(() => null)
338      if (r?.exitCode === 0) {
339        isCopied = true
340        break
341      }
342    }
343  }
344  $.ui.toast(isCopied ? `Copied ${text}: paste it into the prompt` : `Could not reach a clipboard. The command is ${text}`)
345}
346
347// What a press on a command does: run it, or copy it, as the Click switch says.
348async function pressCommand($: EngineInterface, command: string) {
349  if ((await read($, clickMode)) === 'copy') await copyCommand($, command)
350  else await runCommand($, command)
351}
352
353// Turns an installed plugin on or off through the running Claude Code; takes effect on reload.
354async function toggle($: EngineInterface, id: string, isOn: boolean) {
355  const s = await read($, state)
356  const mod = s?.mods.find(m => m.id === id)
357  if (!mod || mod.isSelf) return
358  await update($, busy, () => id)
359  const exe = (await $.env.get('CLAUDE_CODE_EXECPATH').catch(() => undefined)) || 'claude'
360  const r = await $.process.run([exe, 'plugin', isOn ? 'enable' : 'disable', id], { timeoutMs: 60_000 }).catch(() => null)
361  await update($, busy, () => '')
362  if (!r || r.exitCode !== 0) {
363    await update($, note, () => `Could not turn ${mod.name} ${isOn ? 'on' : 'off'}: ${(r?.stderr || r?.stdout || 'claude did not run').trim().split('\n').slice(-1)[0]}`)
364    return
365  }
366  pending = pending.includes(id) ? pending.filter(p => p !== id) : [...pending, id] // a second flip undoes the first
367  await update($, note, () => '')
368  await update($, state, prev => (prev ? { ...prev, pending: [...pending], mods: prev.mods.map(m => (m.id === id ? { ...m, enabled: isOn } : m)) } : prev))
369  $.ui.toast(`${mod.name} turned ${isOn ? 'on' : 'off'}. Reload to apply`)
370}
371
372async function reload($: EngineInterface) {
373  await $.command.run({ command: 'reload-plugins' }).catch(() => $.ui.toast('Run /reload-plugins to apply the changes'))
374}
375
376async function setPins($: EngineInterface, pins: string[]) {
377  const clean = [...new Set(pins.filter(p => typeof p === 'string' && /^[\w:.-]{1,64}$/.test(p)))].slice(0, 12)
378  await $.store.set(PINS_KEY, clean).catch(() => {})
379  await update($, state, prev => (prev ? { ...prev, pins: clean } : prev))
380}
381
382async function togglePin($: EngineInterface, command: string) {
383  const pins = (await read($, state))?.pins ?? []
384  await setPins($, pins.includes(command) ? pins.filter(p => p !== command) : [...pins, command])
385}
386
387// ── The hub server ────────────────────────────────────────────────────────
388
389async function serverInfo($: EngineInterface): Promise<Server | null> {
390  const text = await $.fs.read(`${await configDir($)}/mod-hub/server.json`).catch(() => '')
391  try {
392    const s = JSON.parse(typeof text === 'string' ? text : '') as Server
393    return typeof s.port === 'number' && typeof s.token === 'string' ? s : null
394  } catch {
395    return null
396  }
397}
398
399// What a running hub says about itself; null when nothing answers as mod-hub.
400async function ping($: EngineInterface, s: Server): Promise<{ pid: number; version: number } | null> {
401  const r = await $.http.fetch(`http://127.0.0.1:${s.port}/api/ping`, { headers: { 'x-mod-hub-token': s.token } }).catch(() => null)
402  if (r?.status !== 200) return null
403  try {
404    const p = JSON.parse(r.text) as { ok?: boolean; app?: string; pid?: number; version?: number }
405    return p.ok && p.app === 'mod-hub' ? { pid: Number(p.pid ?? 0), version: Number(p.version ?? 1) } : null
406  } catch {
407    return null
408  }
409}
410
411// Finds a running hub and remembers its address; '' when none answers. A hub older than this
412// mod's server (left running across an update) is replaced, or it would keep serving the old API.
413async function connect($: EngineInterface): Promise<string> {
414  const s = await serverInfo($)
415  let alive = s ? await ping($, s) : null
416  if (s && alive && alive.version < SERVER_VERSION) {
417    await replaceHub($, s, alive.pid)
418    alive = null
419    for (let i = 0; i < 20 && !alive; i++) {
420      await $.clock.sleep(250)
421      const fresh = await serverInfo($)
422      alive = fresh ? await ping($, fresh) : null
423    }
424  }
425  const info = alive ? await serverInfo($) : null
426  const url = info && alive ? `http://127.0.0.1:${info.port}/` : ''
427  await update($, hubUrl, () => url)
428  return url
429}
430
431// Stops an old hub (by /api/shutdown, or for one too old to have it, by its pid: only the pid that
432// answered as mod-hub and that server.json names) and starts this version's.
433async function replaceHub($: EngineInterface, s: Server, pid: number) {
434  const r = await $.http.fetch(`http://127.0.0.1:${s.port}/api/shutdown`, { method: 'POST', headers: { 'x-mod-hub-token': s.token } }).catch(() => null)
435  if (r?.status !== 200 && pid > 0 && pid === s.pid) {
436    const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
437    await $.process.run(isWindows ? ['taskkill', '/PID', String(pid), '/F'] : ['kill', String(pid)], { timeoutMs: 10_000 }).catch(() => null)
438  }
439  await $.clock.sleep(400) // the port frees once the old process is gone
440  await startServer($)
441}
442
443// Starts the hub when it is not running, waits for it to answer, and opens it in the browser.
444async function openHub($: EngineInterface, hash = ''): Promise<string> {
445  let url = await connect($)
446  if (!url) {
447    const started = await startServer($)
448    if (!started) return ''
449    for (let i = 0; i < 20 && !url; i++) {
450      await $.clock.sleep(250)
451      url = await connect($)
452    }
453    if (!url) {
454      await update($, note, () => `the hub did not answer on port ${port}; is something else using it?`)
455      return ''
456    }
457  }
458  lastPushed = '' // a fresh page gets the whole state at once
459  await push($).catch(() => {})
460  await openUrl($, `${url}${hash}`)
461  return url
462}
463
464async function startServer($: EngineInterface): Promise<boolean> {
465  const root = $.plugin.root.replace(/\\/g, '/')
466  const args = [`${root}/server/hub.mjs`, '--dir', `${await configDir($)}/mod-hub`, '--app', `${root}/app`, '--port', String(port)]
467  const isWindows = /^[A-Za-z]:\//.test(root)
468  // Detached, so the hub outlives this call and the session: on Windows WMI starts it as a child of
469  // the WMI service, outside the app's process tree, so closing a window cannot take it down. nohup elsewhere.
470  const line = ['node', ...args].map(a => `"${a}"`).join(' ').replace(/'/g, "''")
471  const argv = isWindows
472    ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow=[uint16]0}; $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='${line}'; ProcessStartupInformation=$si}; if ($r.ReturnValue -ne 0) { [Console]::Error.WriteLine("WMI could not start node: $($r.ReturnValue)"); exit 1 }`]
473    : ['sh', '-c', 'nohup node "$@" >/dev/null 2>&1 &', 'mod-hub', ...args]
474  const r = await $.process.run(argv, { timeoutMs: 15_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
475  if (r.exitCode !== 0) {
476    await update($, note, () => `could not start the hub (is Node.js installed?): ${r.stderr.trim().split('\n').slice(-1)[0]}`)
477    return false
478  }
479  return true
480}
481
482async function openUrl($: EngineInterface, url: string) {
483  const isWindows = /^[A-Za-z]:\//.test($.plugin.root.replace(/\\/g, '/'))
484  const uname = isWindows ? null : await $.process.run(['uname', '-s'], { timeoutMs: 3_000 }).catch(() => null)
485  const argv = isWindows ? ['explorer.exe', url] : [uname?.stdout.trim() === 'Darwin' ? 'open' : 'xdg-open', url]
486  await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
487}
488
489// Sends the state when it changed (and every ~10 s, so the page knows the session is alive).
490async function push($: EngineInterface) {
491  const s = await read($, state)
492  const server = await serverInfo($)
493  if (!s || !server) return
494  const body = JSON.stringify(s)
495  const sameAsLast = body.replace(/"generatedAt":\d+/, '') === lastPushed
496  if (sameAsLast && ticks % 7 !== 0) return
497  const r = await $.http.fetch(`http://127.0.0.1:${server.port}/api/state`, {
498    method: 'POST',
499    headers: { 'content-type': 'application/json', 'x-mod-hub-token': server.token },
500    body,
501  }).catch(() => null)
502  if (r?.ok) lastPushed = body.replace(/"generatedAt":\d+/, '')
503  else await update($, hubUrl, () => '')
504}
505
506// A hub that was up and went away is started again, at most once a minute; why it stopped is in hub.log.
507async function restartLostHub($: EngineInterface) {
508  if (!wasHubUp) return
509  const now = await $.clock.now()
510  if (now - lastRestartAt < 60_000) return
511  lastRestartAt = now
512  if (await startServer($)) $.ui.toast(`The Mod Hub stopped, so it was started again. Why it stopped: ${await configDir($)}/mod-hub/hub.log`)
513}
514
515// One beat: read plugins now and then; while the hub runs, send the state and run what it queued.
516async function tick($: EngineInterface) {
517  ticks++
518  if (ticks % Math.round(REFRESH_MS / TICK_MS) === 0) await refresh($).catch(() => {})
519  if (!(await read($, hubUrl))) {
520    if (ticks % 10 === 0 && !(await connect($))) await restartLostHub($) // a hub started elsewhere joins within 15 s
521    return
522  }
523  wasHubUp = true
524  await push($)
525  const server = await serverInfo($)
526  if (!server) return
527  const r = await $.http.fetch(`http://127.0.0.1:${server.port}/api/actions?after=${lastSeq}`, { headers: { 'x-mod-hub-token': server.token } }).catch(() => null)
528  if (!r?.ok) return
529  let actions: Action[] = []
530  try {
531    actions = (JSON.parse(r.text) as { actions?: Action[] }).actions ?? []
532  } catch {
533    return
534  }
535  for (const a of actions) {
536    if (typeof a.seq !== 'number' || a.seq <= lastSeq) continue
537    lastSeq = a.seq
538    await perform($, a)
539  }
540}
541
542// What the page asked for, checked against this session before it runs.
543async function perform($: EngineInterface, a: Action) {
544  if (a.type === 'run' && typeof a.command === 'string') await runCommand($, a.command, typeof a.args === 'string' ? a.args.slice(0, 500) : '')
545  else if (a.type === 'toggle' && typeof a.id === 'string' && typeof a.on === 'boolean') await toggle($, a.id, a.on)
546  else if (a.type === 'reload') await reload($)
547  else if (a.type === 'pins' && Array.isArray(a.pins)) await setPins($, a.pins)
548  else if (a.type === 'preset-save') await savePreset($, a.preset, 'you')
549  else if (a.type === 'preset-delete' && typeof a.id === 'string') await deletePreset($, a.id)
550  else if (a.type === 'preset-apply' && typeof a.id === 'string') await applyPreset($, a.id)
551  else if (a.type === 'preset-snapshot' && typeof a.name === 'string') await snapshotPreset($, a.name)
552}
553
554// ── The pane ──────────────────────────────────────────────────────────────
555
556async function restoreView($: EngineInterface) {
557  const saved = (await $.store.get(VIEW_KEY).catch(() => undefined)) as { view?: unknown; compact?: unknown } | undefined
558  await update($, view, () => ({ ...cleanView(saved?.view), query: '' })) // the search starts empty each session
559  await update($, compact, () => saved?.compact === true)
560  await update($, clickMode, () => ((saved as { clickMode?: unknown } | undefined)?.clickMode === 'copy' ? 'copy' : 'run'))
561}
562
563async function setClickMode($: EngineInterface, mode: 'run' | 'copy') {
564  await update($, clickMode, () => mode)
565  await $.store.set(VIEW_KEY, { view: { ...(await read($, view)), query: '' }, compact: await read($, compact), clickMode: mode }).catch(() => {})
566}
567
568// Changes the view, starts it at its first page, and keeps it for next session.
569async function setView($: EngineInterface, change: Partial<View>) {
570  const next = await update($, view, v => ({ ...v, ...change }))
571  await update($, page, () => 0)
572  await $.store.set(VIEW_KEY, { view: { ...next, query: '' }, compact: await read($, compact), clickMode: await read($, clickMode) }).catch(() => {})
573}
574
575async function setCompact($: EngineInterface, isCompact: boolean) {
576  await update($, compact, () => isCompact)
577  await update($, page, () => 0)
578  await $.store.set(VIEW_KEY, { view: { ...(await read($, view)), query: '' }, compact: isCompact, clickMode: await read($, clickMode) }).catch(() => {})
579}
580
581// "3 h ago", "2 days ago": how long since a mod was installed.
582export function since(iso: string, now: number): string {
583  const at = Date.parse(iso)
584  if (!Number.isFinite(at)) return ''
585  const m = Math.max(0, Math.round((now - at) / 60_000))
586  if (m < 60) return `${m} min ago`
587  const h = Math.round(m / 60)
588  if (h < 48) return `${h} h ago`
589  return `${Math.round(h / 24)} days ago`
590}
591
592// The strip under the pane's title: one rounded bar split into on, waiting on a reload, and off.
593export function statusStrip(on: number, changed: number, off: number): string {
594  const W = 860
595  const H = 54
596  const total = Math.max(1, on + changed + off)
597  const parts = [
598    { n: on, color: '#e3a857', label: 'on' },
599    { n: changed, color: '#e8d27a', label: 'waiting on a reload' },
600    { n: off, color: '#6b645a', label: 'off' },
601  ].filter(p => p.n > 0)
602  const o = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="Segoe UI, system-ui, sans-serif">`]
603  o.push(`<rect x="0" y="6" width="${W}" height="12" rx="6" fill="#2b2620"/>`)
604  let x = 0
605  parts.forEach((p, i) => {
606    const w = (p.n / total) * W
607    const isFirst = i === 0
608    const isLast = i === parts.length - 1
609    o.push(`<rect x="${x.toFixed(1)}" y="6" width="${Math.max(2, w - (isLast ? 0 : 3)).toFixed(1)}" height="12" rx="${isFirst || isLast ? 6 : 2}" fill="${p.color}"/>`)
610    x += w
611  })
612  let lx = 0
613  for (const p of parts) {
614    o.push(`<circle cx="${lx + 5}" cy="40" r="4.5" fill="${p.color}"/>`)
615    o.push(`<text x="${lx + 15}" y="44.5" font-size="13" fill="#b9b1a2"><tspan font-weight="700" fill="#ece6da">${p.n}</tspan> ${p.label}</text>`)
616    lx += 36 + String(p.n).length * 7.8 + p.label.length * 6.3 // 13 px Segoe: digits bold, words regular
617  }
618  o.push('</svg>')
619  return o.join('')
620}
621
622async function drawPane($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
623  const elements = $.ui.resolve(e)
624  const { Box, Text, Button } = elements
625  const Input = 'Input' in elements ? elements.Input : undefined
626  const Select = 'Select' in elements ? elements.Select : undefined
627  const Svg = 'Svg' in elements ? elements.Svg : undefined
628  const s = await read($, state)
629  if (!s) return <Text dimColor>Reading your mods…</Text>
630  if ((await read($, tab)) === 'presets') return drawPresets($, e, s)
631  const v = await read($, view)
632  const isCompact = await read($, compact)
633  const at = await read($, page)
634  const problem = await read($, note)
635  const working = await read($, busy)
636  const url = await read($, hubUrl)
637  const now = await $.clock.now()
638  const mode = await read($, clickMode)
639
640  const ctx = { pins: s.pins, pending: s.pending, now }
641  // This mod always leads the list, wherever the sort would put it.
642  const shown = viewMods(s.mods, v, ctx).sort((a, b) => Number(b.isSelf) - Number(a.isSelf))
643  const tally = counts(s.mods, v, ctx)
644  const per = isCompact ? PER_PAGE.compact : PER_PAGE.cards
645  const pages = Math.max(1, Math.ceil(shown.length / per))
646  const p = Math.min(at, pages - 1)
647  const on = s.mods.filter(m => m.enabled).length
648  const markets = sources(s.mods)
649  const changedCount = s.pending.length
650
651  const pill = (key: string, text: string, bg: string) => (
652    <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
653      {` ${text} `}
654    </Text>
655  )
656  const flip = (m: ModRow) =>
657    m.isSelf ? (
658      pill('self-on', '◈ ALWAYS ON', 'claude')
659    ) : working === m.id ? (
660      <Text color="suggestion">switching…</Text>
661    ) : (
662      <Button key={`flip-${m.id}`} label={m.enabled ? '● On' : '○ Off'} variant={m.enabled ? 'primary' : undefined} onPress={() => toggle($, m.id, !m.enabled)} />
663    )
664  const commandButtons = (m: ModRow) =>
665    m.commands.map(c => (
666      <Box key={`cmd-${m.id}-${c.name}`} flexDirection="row">
667        <Button key={`run-${c.name}`} label={`${mode === 'copy' ? '⧉' : '▶'} /${c.name}`} onPress={() => pressCommand($, c.name)} />
668        {mode === 'copy' ? (
669          <Button key={`alt-${c.name}`} label="▶" plain dimColor onPress={() => runCommand($, c.name)} />
670        ) : (
671          <Button key={`alt-${c.name}`} label="⧉" plain dimColor onPress={() => copyCommand($, c.name)} />
672        )}
673        <Button key={`pin-${c.name}`} label={s.pins.includes(c.name) ? '★' : '☆'} plain dimColor={!s.pins.includes(c.name)} onPress={() => togglePin($, c.name)} />
674      </Box>
675    ))
676  const badges = (m: ModRow) => [
677    m.isSelf ? pill(`b-self-${m.id}`, 'THIS MOD', 'claude') : null,
678    isNew(m, now) ? pill(`b-new-${m.id}`, 'NEW', 'success') : null,
679    s.pending.includes(m.id) ? pill(`b-chg-${m.id}`, 'RELOAD TO APPLY', 'warning') : null,
680  ]
681
682  return (
683    <Box flexDirection="column" gap={1}>
684      {/* Header: the name, one line of status, and the pane's actions. */}
685      <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" columnGap={2}>
686        <Box flexDirection="column">
687          <Text size="title" weight="bold">
688            <Text color="claude">{'▣ '}</Text>
689            {'Mod Manager'}
690          </Text>
691          <Text size="footnote" dimColor>
692            {`${s.mods.length} installed · ${on} on${changedCount ? ` · ${changedCount} waiting on a reload` : ''}${url ? ' · hub running' : ''}`}
693          </Text>
694        </Box>
695        <Box flexDirection="row" columnGap={1} alignItems="center">
696          <Button key="tab-mods" label="▣ Mods" variant="primary" onPress={() => update($, tab, () => 'mods')} />
697          <Button key="tab-presets" label={`★ Presets${s.presets.length ? ` · ${s.presets.length}` : ''}`} plain onPress={() => update($, tab, () => 'presets')} />
698          {changedCount > 0 && <Button key="reload" label={`↻ Reload · ${changedCount}`} variant="primary" onPress={() => reload($)} />}
699          <Button key="hub" label={url ? '◈ Open Hub' : '◈ Start Hub'} onPress={() => void openHub($)} />
700          <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
701        </Box>
702      </Box>
703
704      {Svg && (
705        <Svg
706          key="strip"
707          source={statusStrip(
708            s.mods.filter(m => m.enabled && !s.pending.includes(m.id)).length,
709            changedCount,
710            s.mods.filter(m => !m.enabled && !s.pending.includes(m.id)).length,
711          )}
712          alt={`${on} of ${s.mods.length} mods on`}
713        />
714      )}
715
716      {/* Toolbar: search, filters and view settings in one card. */}
717      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1} gap={1}>
718        {Input && (
719          <Input key="search" placeholder="Search mods and commands…" value={v.query} submitLabel="search" onInput={q => setView($, { query: q })} onSubmit={q => setView($, { query: q })} />
720        )}
721        <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
722          {FILTERS.filter(f => f.value === 'all' || tally[f.value] > 0 || v.filter === f.value).map(f => (
723            <Button
724              key={`filter-${f.value}`}
725              label={`${f.label} ${tally[f.value]}`}
726              variant={v.filter === f.value ? 'primary' : undefined}
727              plain={v.filter === f.value ? undefined : true}
728              dimColor={v.filter !== f.value}
729              onPress={() => setView($, { filter: f.value })}
730            />
731          ))}
732        </Box>
733        <Box flexDirection="row" flexWrap="wrap" columnGap={2} alignItems="center">
734          {Select && <Select key="sort" label="Sort" options={SORTS} value={v.sort} onSelect={value => setView($, { sort: value as View['sort'] })} />}
735          {Select && markets.length > 1 && (
736            <Select
737              key="source"
738              label="From"
739              options={[{ value: '', label: 'Every marketplace' }, ...markets.map(m => ({ value: m, label: `@${m}` }))]}
740              value={v.source}
741              onSelect={value => setView($, { source: value })}
742            />
743          )}
744          <Button key="layout" label={isCompact ? '▦ Cards' : '▤ Compact'} plain onPress={() => setCompact($, !isCompact)} />
745          <Button key="click-mode" label={mode === 'copy' ? 'Click: ⧉ Copy' : 'Click: ▶ Run'} variant={mode === 'copy' ? 'primary' : undefined} onPress={() => setClickMode($, mode === 'copy' ? 'run' : 'copy')} />
746          {!isDefault(v) && <Button key="reset" label="✕ Reset" plain dimColor onPress={() => setView($, { ...DEFAULT_VIEW })} />}
747        </Box>
748      </Box>
749
750      {problem !== '' && (
751        <Box flexDirection="row" columnGap={1} borderStyle="round" borderColor="error" paddingX={1}>
752          <Text color="error" wrap="wrap">
753            {problem}
754          </Text>
755          <Button key="dismiss-note" label="Dismiss" plain dimColor onPress={() => update($, note, () => '')} />
756        </Box>
757      )}
758
759      <Text size="caption" weight="semibold" dimColor>
760        {shown.length === 0 ? 'NOTHING SHOWN' : `SHOWING ${p * per + 1}–${Math.min(shown.length, (p + 1) * per)} OF ${shown.length}${shown.length < s.mods.length ? ` · ${s.mods.length} INSTALLED` : ''}`}
761      </Text>
762
763      {shown.length === 0 && (
764        <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="inactive" paddingY={1}>
765          <Text dimColor>{`No mod is ${FILTERS.find(f => f.value === v.filter)?.hint ?? 'here'}${v.query ? ` and matches "${v.query}"` : ''}.`}</Text>
766          <Button key="reset-empty" label="Show all mods" plain onPress={() => setView($, { ...DEFAULT_VIEW })} />
767        </Box>
768      )}
769
770      {isCompact ? (
771        <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
772          {shown.slice(p * per, (p + 1) * per).map(m => (
773            <Box key={`row-${m.id}`} flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
774              {flip(m)}
775              <Text weight="semibold" dimColor={!m.enabled} color={m.isSelf ? 'claude' : undefined}>
776                {m.isSelf ? `◈ ${m.name}` : m.name}
777              </Text>
778              {badges(m)}
779              {commandButtons(m)}
780            </Box>
781          ))}
782        </Box>
783      ) : (
784        shown.slice(p * per, (p + 1) * per).map(m => (
785          <Box
786            key={`mod-${m.id}`}
787            flexDirection="column"
788            borderStyle={m.isSelf ? 'double' : 'round'}
789            borderColor={m.isSelf ? 'claude' : m.enabled ? 'success' : 'inactive'}
790            borderDimColor={!m.enabled && !m.isSelf}
791            hover={{ borderColor: 'claude', borderDimColor: false }}
792            paddingX={1}
793          >
794            <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1}>
795              <Text wrap="truncate-end">
796                <Text size="heading" weight="semibold" dimColor={!m.enabled} color={m.isSelf ? 'claude' : undefined}>
797                  {m.isSelf ? `◈ ${m.name}` : m.name}
798                </Text>
799                <Text size="footnote" mono dimColor>{`  v${m.version}`}</Text>
800              </Text>
801              {flip(m)}
802            </Box>
803            {badges(m).some(Boolean) && (
804              <Box flexDirection="row" columnGap={1}>
805                {badges(m)}
806              </Box>
807            )}
808            {m.isSelf && (
809              <Text size="footnote" color="claude">
810                The Mod Manager you are using. It stays on so you can always get back here.
811              </Text>
812            )}
813            {m.description !== '' && (
814              <Text size="footnote" dimColor wrap="wrap">
815                {m.description.length > 170 ? `${m.description.slice(0, 169)}…` : m.description}
816              </Text>
817            )}
818            <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
819              {m.commands.length === 0 && <Text size="footnote" dimColor italic>{m.enabled ? 'No commands: it works by itself, above the prompt or in hooks.' : 'Off: its commands come back once it is on and reloaded.'}</Text>}
820              {commandButtons(m)}
821            </Box>
822            <Box flexDirection="row" justifyContent="space-between" alignItems="center">
823              <Text size="caption" dimColor>
824                {`@${m.marketplace}${m.installedAt ? ` · installed ${since(m.installedAt, now)}` : ''}`}
825              </Text>
826              <Button key={`open-${m.id}`} label="Open in Hub ↗" plain dimColor onPress={() => void openHub($, `#/mod/${encodeURIComponent(m.id)}`)} />
827            </Box>
828          </Box>
829        ))
830      )}
831
832      {pages > 1 && (
833        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
834          <Button key="prev" label="‹ Prev" plain dimColor={p === 0} onPress={() => update($, page, n => Math.max(0, n - 1))} />
835          <Text size="footnote" dimColor tabular>{`page ${p + 1} of ${pages}`}</Text>
836          <Button key="next" label="Next ›" plain dimColor={p >= pages - 1} onPress={() => update($, page, n => Math.min(pages - 1, n + 1))} />
837        </Box>
838      )}
839    </Box>
840  )
841}
842
843// ── Recent commands ───────────────────────────────────────────────────────
844
845async function remember($: EngineInterface, command: string, args: string) {
846  const at = await $.clock.now()
847  const list = await update($, recentAtom, prev => [{ command, args: args.trim(), at }, ...prev.filter(r => !(r.command === command && r.args === args.trim()))].slice(0, MAX_RECENT))
848  await update($, state, prev => (prev ? { ...prev, recent: list } : prev))
849}
850
851// ── The prompt cache ──────────────────────────────────────────────────────
852
853async function restoreCache($: EngineInterface) {
854  if (ttlOverride !== null) {
855    ttlMs = ttlOverride
856    isTtlLearned = true
857    return
858  }
859  const known = await $.store.get(TTL_KEY).catch(() => undefined)
860  if (known === '1h') {
861    ttlMs = HOUR
862    isTtlLearned = true
863  }
864  if ((await read($, cacheAt)) > 0) startBeat($)
865}
866
867// A request finished. One that still read from the cache more than five minutes after the
868// last shows the cache lasts an hour here; that is remembered for later sessions.
869async function sawRequest($: EngineInterface, cacheRead: number) {
870  const now = await $.clock.now()
871  const prev = await read($, cacheAt)
872  if (ttlOverride === null && ttlMs < HOUR && prev > 0 && now - prev > MIN5 + 10_000 && now - prev < HOUR && cacheRead > 0) {
873    ttlMs = HOUR
874    isTtlLearned = true
875    await $.store.set(TTL_KEY, '1h').catch(() => {})
876  }
877  await update($, cacheAt, () => now)
878  await update($, state, prev2 => (prev2 ? { ...prev2, cache: { at: now, ttlMs, isLearned: isTtlLearned } } : prev2))
879  startBeat($)
880}
881
882// Ticks once a second while the cache is warm, so the bar's countdown moves; stops once it is cold.
883function startBeat($: EngineInterface) {
884  beatTimer?.cancel()
885  beatTimer = $.clock.every(1_000, () => {
886    void tickBeat($).catch(() => {})
887  })
888}
889
890async function tickBeat($: EngineInterface) {
891  const at = await read($, cacheAt)
892  if (!at || (await $.clock.now()) - at > ttlMs) {
893    beatTimer?.cancel()
894    beatTimer = undefined
895  }
896  await update($, beat, n => n + 1)
897}
898
899// The bar's cache part: how long it stays warm, or that it went cold.
900async function cacheLine($: EngineInterface): Promise<{ text: string; color?: string; isDim: boolean }> {
901  await read($, beat) // redraws each second while warm
902  const at = await read($, cacheAt)
903  if (!at) return { text: '◷ cache · no request yet', isDim: true }
904  const left = at + ttlMs - (await $.clock.now())
905  const life = ttlMs >= HOUR ? '1 h' : '5 min'
906  if (left <= 0) return { text: `◷ cache cold · next message re-reads it (${life} cache)`, isDim: true }
907  return { text: `◷ cache warm · ${countdown(left)} left`, color: left <= 60_000 ? 'warning' : 'success', isDim: false }
908}
909
910// ── Presets: storing ──────────────────────────────────────────────────────
911
912async function loadPresets($: EngineInterface): Promise<Preset[]> {
913  const raw = await $.store.get(PRESETS_KEY).catch(() => undefined)
914  const now = await $.clock.now()
915  return (Array.isArray(raw) ? raw : []).map(p => cleanPreset(p, now)).filter((p): p is Preset => p !== null && p.id !== '')
916}
917
918async function storePresets($: EngineInterface, list: Preset[]) {
919  await $.store.set(PRESETS_KEY, list).catch(() => {})
920  await update($, state, prev => (prev ? { ...prev, presets: list } : prev))
921}
922
923// Saves one (new, or by its id); returns it as stored, or null when it was not a preset.
924async function savePreset($: EngineInterface, raw: unknown, by: 'you' | 'claude'): Promise<Preset | null> {
925  const now = await $.clock.now()
926  const clean = cleanPreset(raw, now)
927  if (!clean) return null
928  const list = upsert(await loadPresets($), { ...clean, by: by === 'claude' ? 'claude' : clean.by }, now)
929  await storePresets($, list)
930  return list.find(p => p.name === clean.name && (clean.id ? p.id === clean.id : true)) ?? list.at(-1) ?? null
931}
932
933async function deletePreset($: EngineInterface, id: string) {
934  await storePresets($, (await loadPresets($)).filter(p => p.id !== id))
935  if ((await read($, editing)) === id) await update($, editing, () => '')
936}
937
938// Changes one preset in place.
939async function editPreset($: EngineInterface, id: string, change: (p: Preset) => Preset) {
940  const list = await loadPresets($)
941  const now = await $.clock.now()
942  await storePresets($, list.map(p => (p.id === id ? { ...change(p), id, updatedAt: now } : p)))
943}
944
945// The current setup, saved under a name.
946async function snapshotPreset($: EngineInterface, name: string) {
947  const s = await read($, state)
948  const clean = name.trim().slice(0, 60)
949  if (!s || !clean) {
950    $.ui.toast('Give the preset a name first')
951    return
952  }
953  const saved = await savePreset($, snapshot(clean, s.mods, s.pins, await read($, recentAtom), await $.clock.now()), 'you')
954  await update($, draftName, () => '')
955  if (saved) {
956    await update($, editing, () => saved.id)
957    $.ui.toast(`Saved preset "${saved.name}": ${summary(saved)}`)
958  }
959}
960
961// ── Presets: applying ─────────────────────────────────────────────────────
962
963// Turns its mods on or off, sets its pins, and runs its commands. A mod change needs a reload
964// first, so the commands are kept for after it and run once the mods are back.
965async function applyPreset($: EngineInterface, id: string) {
966  const s = await read($, state)
967  const preset = (await loadPresets($)).find(p => p.id === id)
968  if (!s || !preset) return
969  const { flips, steps, missing } = plan(preset, s.mods.filter(m => !m.isSelf))
970  for (const f of flips) await toggle($, f.id, f.on)
971  if (preset.pins) await setPins($, preset.pins)
972  if (missing.length) $.ui.toast(`${preset.name}: ${missing.length} of its mods are not installed here, so they were skipped`)
973  if (flips.length > 0) {
974    await $.store.set(APPLY_KEY, { id, at: await $.clock.now() }).catch(() => {})
975    $.ui.toast(`Applying ${preset.name}: reloading for ${flips.length} mod${flips.length === 1 ? '' : 's'}${steps.length ? `, then running ${steps.length} command${steps.length === 1 ? '' : 's'}` : ''}`)
976    await reload($)
977    return
978  }
979  await runSteps($, preset)
980}
981
982async function runSteps($: EngineInterface, preset: Preset) {
983  const steps = preset.steps.filter(x => x.enabled)
984  for (const step of steps) await runCommand($, step.command, step.args)
985  $.ui.toast(steps.length ? `${preset.name} applied: ran ${steps.map(x => `/${x.command}`).join(', ')}` : `${preset.name} applied`)
986}
987
988// After the reload a preset asked for: run the commands it still owes, once, if it was recent.
989async function resumeApply($: EngineInterface) {
990  const owed = (await $.store.get(APPLY_KEY).catch(() => undefined)) as { id?: string; at?: number } | undefined
991  if (!owed?.id) return
992  await $.store.delete(APPLY_KEY).catch(() => {})
993  if ((await $.clock.now()) - (owed.at ?? 0) > 120_000) return
994  const preset = (await loadPresets($)).find(p => p.id === owed.id)
995  if (preset) await runSteps($, preset)
996}
997
998// ── Presets: Claude's tool ────────────────────────────────────────────────
999
1000async function presetTool($: EngineInterface, input: Record<string, unknown>) {
1001  const s = await read($, state)
1002  if (!s) return { deny: 'mod-hub is still reading the installed mods; try again in a moment.' }
1003  const action = input.action
1004  if (action === 'list') {
1005    const mods = s.mods
1006      .filter(m => !m.isSelf)
1007      .map(m => `- ${m.id} (${m.enabled ? 'on' : 'off'})${m.commands.length ? `: ${m.commands.map(c => `/${c.name}${isToggle(c.description) ? ' [toggle]' : ''}`).join(', ')}` : ''}`)
1008    const presets = s.presets.map(p => `- ${p.id} "${p.name}" by ${p.by}: ${summary(p)}`)
1009    const recent = s.recent.map(r => `/${r.command}${r.args ? ` ${r.args}` : ''}`)
1010    return {
1011      result: [
1012        `Installed mods (${s.mods.length - 1}):`,
1013        ...mods,
1014        '',
1015        `Recently run: ${recent.length ? recent.join(', ') : 'nothing yet'}`,
1016        `Pins on the bar: ${s.pins.map(x => `/${x}`).join(', ') || 'none'}`,
1017        '',
1018        presets.length ? `Presets (${presets.length}):` : 'No presets yet.',
1019        ...presets,
1020      ].join('\n'),
1021    }
1022  }
1023  if (action === 'delete') {
1024    const id = typeof input.id === 'string' ? input.id : ''
1025    if (!s.presets.some(p => p.id === id)) return { deny: `No preset with id ${id}. Use action list to see them.` }
1026    await deletePreset($, id)
1027    return { result: `Deleted preset ${id}.` }
1028  }
1029  if (action === 'save') {
1030    const clean = cleanPreset(input.preset, await $.clock.now())
1031    if (!clean) return { deny: 'A preset needs at least a name. Mods map plugin ids to "on" or "off"; steps are { command, args, enabled }.' }
1032    const ids = new Set(s.mods.map(m => m.id))
1033    const unknownMods = Object.keys(clean.mods).filter(id => !ids.has(id))
1034    const known = new Set([...s.mods.flatMap(m => m.commands.map(c => c.name)), ...s.otherCommands.map(c => c.name)])
1035    const unknownSteps = clean.steps.filter(x => !known.has(x.command)).map(x => `/${x.command}`)
1036    if (unknownMods.length) return { deny: `Not installed here: ${unknownMods.join(', ')}. Use action list for the plugin ids.` }
1037    const saved = await savePreset($, clean, 'claude')
1038    if (!saved) return { deny: 'That preset could not be saved.' }
1039    return {
1040      result:
1041        `Saved preset "${saved.name}" (id ${saved.id}): ${summary(saved)}.` +
1042        (unknownSteps.length ? ` Note: ${unknownSteps.join(', ')} ${unknownSteps.length === 1 ? 'is' : 'are'} not a command right now; it runs only if its mod is on when the preset is applied.` : '') +
1043        ' The person applies it from ★ Presets in the Mod Manager, or the hub.',
1044    }
1045  }
1046  return { deny: 'action must be list, save or delete' }
1047}
1048
1049// ── Presets: the pane ─────────────────────────────────────────────────────
1050
1051async function drawPresets($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], s: HubState) {
1052  const elements = $.ui.resolve(e)
1053  const { Box, Text, Button } = elements
1054  const Input = 'Input' in elements ? elements.Input : undefined
1055  const open = await read($, editing)
1056  const doomed = await read($, confirmDelete)
1057  const name = await read($, draftName)
1058  const recent = await read($, recentAtom)
1059  const describe = new Map(s.mods.flatMap(m => m.commands.map(c => [c.name, c.description] as const)))
1060  const known = new Set([...describe.keys(), ...s.otherCommands.map(c => c.name)])
1061  const others = s.mods.filter(m => !m.isSelf)
1062
1063  const pill = (key: string, text: string, bg: string) => (
1064    <Text key={key} backgroundColor={bg} color="inverseText" weight="semibold" size="caption">
1065      {` ${text} `}
1066    </Text>
1067  )
1068  const caption = (key: string, text: string) => (
1069    <Text key={key} size="caption" weight="semibold" dimColor>
1070      {text}
1071    </Text>
1072  )
1073
1074  return (
1075    <Box flexDirection="column" gap={1}>
1076      <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" columnGap={2}>
1077        <Box flexDirection="column">
1078          <Text size="title" weight="bold">
1079            <Text color="claude">{'★ '}</Text>
1080            {'Presets'}
1081          </Text>
1082          <Text size="footnote" dimColor>
1083            {s.presets.length ? `${s.presets.length} saved · apply one to switch your whole setup` : 'Save a setup once, switch back to it in one press'}
1084          </Text>
1085        </Box>
1086        <Box flexDirection="row" columnGap={1} alignItems="center">
1087          <Button key="tab-mods" label="▣ Mods" plain onPress={() => update($, tab, () => 'mods')} />
1088          <Button key="tab-presets" label={`★ Presets${s.presets.length ? ` · ${s.presets.length}` : ''}`} variant="primary" onPress={() => update($, tab, () => 'presets')} />
1089          <Button key="close" label="✕" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
1090        </Box>
1091      </Box>
1092
1093      {/* New preset: the current setup under a name. */}
1094      <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} gap={1}>
1095        <Text size="heading" weight="semibold">
1096          New preset from your current setup
1097        </Text>
1098        <Text size="footnote" dimColor wrap="wrap">
1099          {`Keeps every mod's on or off, your ${s.pins.length} pin${s.pins.length === 1 ? '' : 's'}, and the commands you ran lately${recent.length ? `: ${recent.slice(0, 6).map(r => `/${r.command}${r.args ? ` ${r.args}` : ''}`).join(', ')}` : ' (none yet: run some, or add them after)'}. Toggles go in unticked.`}
1100        </Text>
1101        <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
1102          {Input && <Input key="new-name" placeholder="Name it: Focus, Streaming, Everything on…" value={name} submitLabel="save" onInput={v => update($, draftName, () => v)} onSubmit={v => snapshotPreset($, v)} />}
1103          <Button key="snapshot" label="★ Save current setup" variant="primary" onPress={async () => snapshotPreset($, await read($, draftName))} />
1104        </Box>
1105        <Text size="caption" dimColor>
1106          Or ask Claude: "make me a preset for streaming with snake off". It can save presets for you; only you apply them.
1107        </Text>
1108      </Box>
1109
1110      {s.presets.length === 0 && (
1111        <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="inactive" paddingY={1}>
1112          <Text dimColor>No presets yet.</Text>
1113        </Box>
1114      )}
1115
1116      {s.presets.map(p => {
1117        const isOpen = open === p.id
1118        const { flips, steps, missing } = plan(p, others)
1119        const willDo = [
1120          flips.length ? `turn ${flips.filter(f => f.on).length ? `${flips.filter(f => f.on).length} on` : ''}${flips.filter(f => f.on).length && flips.filter(f => !f.on).length ? ', ' : ''}${flips.filter(f => !f.on).length ? `${flips.filter(f => !f.on).length} off` : ''} (then reload)` : '',
1121          steps.length ? `run ${steps.length} command${steps.length === 1 ? '' : 's'}` : '',
1122          p.pins ? 'set the pins' : '',
1123        ].filter(Boolean)
1124        return (
1125          <Box key={`preset-${p.id}`} flexDirection="column" borderStyle="round" borderColor={isOpen ? 'claude' : 'inactive'} hover={{ borderColor: 'claude' }} paddingX={1}>
1126            <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} flexWrap="wrap">
1127              <Box flexDirection="row" columnGap={1} alignItems="center">
1128                <Text size="heading" weight="semibold">
1129                  {p.name}
1130                </Text>
1131                {p.by === 'claude' && pill(`by-${p.id}`, 'BY CLAUDE', 'suggestion')}
1132              </Box>
1133              <Box flexDirection="row" columnGap={1} alignItems="center">
1134                <Button key={`apply-${p.id}`} label="▶ Apply" variant="primary" onPress={() => applyPreset($, p.id)} />
1135                <Button key={`edit-${p.id}`} label={isOpen ? '✓ Done' : '✎ Edit'} plain onPress={() => update($, editing, cur => (cur === p.id ? '' : p.id))} />
1136                <Button key={`copy-${p.id}`} label="⧉" plain dimColor onPress={() => savePreset($, { ...p, id: '', name: `${p.name} copy`, by: 'you' }, 'you')} />
1137                <Button
1138                  key={`delete-${p.id}`}
1139                  label={doomed === p.id ? 'Press again to delete' : '✕'}
1140                  plain={doomed === p.id ? undefined : true}
1141                  variant={doomed === p.id ? 'primary' : undefined}
1142                  dimColor={doomed !== p.id}
1143                  onPress={async () => {
1144                    if ((await read($, confirmDelete)) === p.id) {
1145                      await deletePreset($, p.id)
1146                      await update($, confirmDelete, () => '')
1147                    } else await update($, confirmDelete, () => p.id)
1148                  }}
1149                />
1150              </Box>
1151            </Box>
1152            {p.note !== '' && (
1153              <Text size="footnote" dimColor wrap="wrap">
1154                {p.note}
1155              </Text>
1156            )}
1157            <Text size="footnote" wrap="wrap">
1158              {summary(p)}
1159            </Text>
1160            <Text size="caption" dimColor wrap="wrap">
1161              {willDo.length ? `Applying now would ${willDo.join(', ')}.` : 'Applying now changes nothing: everything already matches.'}
1162              {missing.length ? ` ${missing.length} of its mods are not installed here.` : ''}
1163            </Text>
1164
1165            {isOpen && (
1166              <Box flexDirection="column" gap={1} paddingTop={1}>
1167                {Input && (
1168                  <Input key={`rename-${p.id}`} label="Name" placeholder="Preset name" value={p.name} submitLabel="rename" onSubmit={v => editPreset($, p.id, x => ({ ...x, name: v.trim() || x.name }))} />
1169                )}
1170                {Input && <Input key={`note-${p.id}`} label="Note" placeholder="What it is for (optional)" value={p.note} submitLabel="save" onSubmit={v => editPreset($, p.id, x => ({ ...x, note: v.slice(0, 300) }))} />}
1171
1172                {caption(`cap-mods-${p.id}`, 'MODS  ·  leave as is, or turn on or off when applied')}
1173                {others.map(m => {
1174                  const want = p.mods[m.id]
1175                  const set = (v: 'on' | 'off' | undefined) =>
1176                    editPreset($, p.id, x => {
1177                      const mods = { ...x.mods }
1178                      if (v) mods[m.id] = v
1179                      else delete mods[m.id]
1180                      return { ...x, mods }
1181                    })
1182                  return (
1183                    <Box key={`pm-${p.id}-${m.id}`} flexDirection="row" columnGap={1} alignItems="center">
1184                      <Button key={`pm-leave-${m.id}`} label="·" variant={!want ? 'primary' : undefined} plain={want ? true : undefined} dimColor={!!want} onPress={() => set(undefined)} />
1185                      <Button key={`pm-on-${m.id}`} label="On" variant={want === 'on' ? 'primary' : undefined} plain={want === 'on' ? undefined : true} dimColor={want !== 'on'} onPress={() => set('on')} />
1186                      <Button key={`pm-off-${m.id}`} label="Off" variant={want === 'off' ? 'primary' : undefined} plain={want === 'off' ? undefined : true} dimColor={want !== 'off'} onPress={() => set('off')} />
1187                      <Text weight={want ? 'semibold' : undefined} dimColor={!want}>
1188                        {m.name}
1189                      </Text>
1190                      <Text size="caption" dimColor>{m.enabled ? 'now on' : 'now off'}</Text>
1191                    </Box>
1192                  )
1193                })}
1194
1195                {caption(`cap-steps-${p.id}`, 'COMMANDS  ·  run in order when applied')}
1196                {p.steps.length === 0 && <Text size="footnote" dimColor>None yet. Add one below.</Text>}
1197                {p.steps.map((step, i) => {
1198                  const toggleLike = isToggle(describe.get(step.command) ?? '')
1199                  const move = (to: number) =>
1200                    editPreset($, p.id, x => {
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
shared/view.ts 109 lines
1// Which mods a view shows, and in what order. Pure, with no `claude-code` import, so the
2// /mod-manager pane and the web hub (web/src imports this file; it sits outside hooks/ so Vite finds shared/tsconfig.json) filter exactly alike.
3
4export type Filter = 'all' | 'on' | 'off' | 'pinned' | 'commands' | 'changed' | 'new'
5export type Sort = 'status' | 'name' | 'newest' | 'commands'
6
7export type ViewMod = {
8  id: string
9  name: string
10  marketplace: string
11  description: string
12  enabled: boolean
13  installedAt: string
14  commands: { name: string; description: string }[]
15}
16
17export type View = { query: string; filter: Filter; sort: Sort; source: string } // source '' is every marketplace
18export type Context = { pins: readonly string[]; pending: readonly string[]; now: number }
19
20export const DEFAULT_VIEW: View = { query: '', filter: 'all', sort: 'status', source: '' }
21export const NEW_MS = 24 * 60 * 60 * 1000
22
23export const FILTERS: { value: Filter; label: string; hint: string }[] = [
24  { value: 'all', label: 'All', hint: 'every installed mod' },
25  { value: 'on', label: 'On', hint: 'turned on' },
26  { value: 'off', label: 'Off', hint: 'turned off' },
27  { value: 'pinned', label: 'Pinned', hint: 'with a command on the bar' },
28  { value: 'commands', label: 'Commands', hint: 'with slash commands' },
29  { value: 'changed', label: 'Changed', hint: 'turned on or off, waiting on a reload' },
30  { value: 'new', label: 'New', hint: 'installed in the last day' },
31]
32
33export const SORTS: { value: Sort; label: string }[] = [
34  { value: 'status', label: 'On first' },
35  { value: 'name', label: 'A to Z' },
36  { value: 'newest', label: 'Newest' },
37  { value: 'commands', label: 'Most commands' },
38]
39
40export function isNew(m: ViewMod, now: number): boolean {
41  const at = Date.parse(m.installedAt)
42  return Number.isFinite(at) && now - at < NEW_MS
43}
44
45export function matches(m: ViewMod, filter: Filter, ctx: Context): boolean {
46  switch (filter) {
47    case 'on':
48      return m.enabled
49    case 'off':
50      return !m.enabled
51    case 'pinned':
52      return m.commands.some(c => ctx.pins.includes(c.name))
53    case 'commands':
54      return m.commands.length > 0
55    case 'changed':
56      return ctx.pending.includes(m.id)
57    case 'new':
58      return isNew(m, ctx.now)
59    default:
60      return true
61  }
62}
63
64// Every word of the query must appear in the mod's name, description, marketplace or a command.
65export function matchesQuery(m: ViewMod, query: string): boolean {
66  const words = query.toLowerCase().replace(/\//g, ' ').split(/\s+/).filter(Boolean)
67  if (words.length === 0) return true
68  const hay = `${m.name} ${m.description} ${m.marketplace} ${m.commands.map(c => `${c.name} ${c.description}`).join(' ')}`.toLowerCase()
69  return words.every(w => hay.includes(w))
70}
71
72const ORDER: Record<Sort, (a: ViewMod, b: ViewMod) => number> = {
73  status: (a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name),
74  name: (a, b) => a.name.localeCompare(b.name),
75  newest: (a, b) => (Date.parse(b.installedAt) || 0) - (Date.parse(a.installedAt) || 0) || a.name.localeCompare(b.name),
76  commands: (a, b) => b.commands.length - a.commands.length || a.name.localeCompare(b.name),
77}
78
79export function viewMods<M extends ViewMod>(mods: readonly M[], view: View, ctx: Context): M[] {
80  return mods
81    .filter(m => (!view.source || m.marketplace === view.source) && matches(m, view.filter, ctx) && matchesQuery(m, view.query))
82    .sort(ORDER[view.sort] ?? ORDER.status)
83}
84
85// How many mods each filter would show, within the chosen marketplace and search, for the chips.
86export function counts(mods: readonly ViewMod[], view: View, ctx: Context): Record<Filter, number> {
87  const pool = mods.filter(m => (!view.source || m.marketplace === view.source) && matchesQuery(m, view.query))
88  return Object.fromEntries(FILTERS.map(f => [f.value, pool.filter(m => matches(m, f.value, ctx)).length])) as Record<Filter, number>
89}
90
91export function sources(mods: readonly ViewMod[]): string[] {
92  return [...new Set(mods.map(m => m.marketplace).filter(Boolean))].sort()
93}
94
95// A stored or posted view, with anything unknown put back to its default.
96export function cleanView(v: unknown): View {
97  const o = (v && typeof v === 'object' ? v : {}) as Partial<Record<keyof View, unknown>>
98  return {
99    query: typeof o.query === 'string' ? o.query.slice(0, 200) : '',
100    filter: FILTERS.some(f => f.value === o.filter) ? (o.filter as Filter) : 'all',
101    sort: SORTS.some(s => s.value === o.sort) ? (o.sort as Sort) : 'status',
102    source: typeof o.source === 'string' ? o.source.slice(0, 200) : '',
103  }
104}
105
106export function isDefault(v: View): boolean {
107  return v.query === '' && v.filter === 'all' && v.sort === 'status' && v.source === ''
108}
109
shared/presets.ts 137 lines
1// Presets: a named setup to come back to. A preset can turn mods on or off (only the mods it
2// names), set the bar's pins, and run a list of commands. Pure, with no `claude-code` import:
3// the mod applies presets, and the web hub (web/src imports this file) edits them the same way.
4
5export type ModState = 'on' | 'off'
6export type PresetStep = { command: string; args: string; enabled: boolean }
7export type Preset = {
8  id: string
9  name: string
10  note: string
11  mods: Record<string, ModState> // plugin id → wanted state; a mod not listed is left as it is
12  steps: PresetStep[]
13  pins: string[] | null // null leaves the bar's pins alone
14  by: 'you' | 'claude'
15  updatedAt: number
16}
17export type RecentCommand = { command: string; args: string; at: number }
18
19export const MAX_PRESETS = 30
20export const MAX_STEPS = 20
21const NAME = /^[\w:.-]{1,64}$/
22
23// A command that flips something each time it runs: applying a preset twice would undo it.
24export function isToggle(description: string): boolean {
25  return /\b(show or hide|shows or hides|toggle|turns? it (?:on|off)|on or off|again turns|hide it|hides it)\b/i.test(description)
26}
27
28export function newId(name: string, taken: readonly string[]): string {
29  const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'preset'
30  let id = base
31  for (let i = 2; taken.includes(id); i++) id = `${base}-${i}`
32  return id
33}
34
35// A preset from anywhere (the store, the web page, Claude's tool), kept to what is valid:
36// known shapes, bounded sizes, command names that could be commands.
37export function cleanPreset(raw: unknown, now: number): Preset | null {
38  if (!raw || typeof raw !== 'object') return null
39  const p = raw as Record<string, unknown>
40  const name = typeof p.name === 'string' ? p.name.trim().slice(0, 60) : ''
41  if (!name) return null
42  const mods: Record<string, ModState> = {}
43  if (p.mods && typeof p.mods === 'object') {
44    for (const [id, state] of Object.entries(p.mods as Record<string, unknown>).slice(0, 100)) {
45      if (/^[\w.:@-]{1,200}$/.test(id) && (state === 'on' || state === 'off')) mods[id] = state
46    }
47  }
48  const steps: PresetStep[] = []
49  for (const s of Array.isArray(p.steps) ? p.steps.slice(0, MAX_STEPS) : []) {
50    if (!s || typeof s !== 'object') continue
51    const step = s as Record<string, unknown>
52    const command = typeof step.command === 'string' ? step.command.trim().replace(/^\//, '') : ''
53    if (!NAME.test(command)) continue
54    steps.push({ command, args: typeof step.args === 'string' ? step.args.slice(0, 300) : '', enabled: step.enabled !== false })
55  }
56  const pins = Array.isArray(p.pins) ? [...new Set(p.pins.filter((x): x is string => typeof x === 'string' && NAME.test(x)))].slice(0, 12) : null
57  return {
58    id: typeof p.id === 'string' && /^[\w-]{1,60}$/.test(p.id) ? p.id : '',
59    name,
60    note: typeof p.note === 'string' ? p.note.slice(0, 300) : '',
61    mods,
62    steps,
63    pins,
64    by: p.by === 'claude' ? 'claude' : 'you',
65    updatedAt: typeof p.updatedAt === 'number' ? p.updatedAt : now,
66  }
67}
68
69// Saves a preset into the list: a new id for a new one, an update in place for a known id.
70export function upsert(list: readonly Preset[], preset: Preset, now: number): Preset[] {
71  const known = preset.id && list.some(p => p.id === preset.id)
72  const id = known ? preset.id : newId(preset.name, list.map(p => p.id))
73  const saved = { ...preset, id, updatedAt: now }
74  return known ? list.map(p => (p.id === id ? saved : p)) : [...list, saved].slice(-MAX_PRESETS)
75}
76
77// The current setup as a preset: every mod as it is now, the bar's pins, and the commands the
78// person ran recently, in order, once each. Toggles go in unticked, so applying it changes nothing.
79export function snapshot(
80  name: string,
81  mods: readonly { id: string; enabled: boolean; isSelf: boolean; commands: { name: string; description: string }[] }[],
82  pins: readonly string[],
83  recent: readonly RecentCommand[],
84  now: number,
85): Preset {
86  const describe = new Map(mods.flatMap(m => m.commands.map(c => [c.name, c.description] as const)))
87  const seen = new Set<string>()
88  const steps: PresetStep[] = []
89  for (const r of recent) {
90    const key = `${r.command} ${r.args}`.trim()
91    if (seen.has(key) || !describe.has(r.command)) continue
92    seen.add(key)
93    steps.push({ command: r.command, args: r.args, enabled: !isToggle(describe.get(r.command) ?? '') })
94  }
95  return {
96    id: '',
97    name,
98    note: '',
99    mods: Object.fromEntries(mods.filter(m => !m.isSelf).map(m => [m.id, m.enabled ? 'on' : 'off'])),
100    steps: steps.slice(0, MAX_STEPS),
101    pins: [...pins],
102    by: 'you',
103    updatedAt: now,
104  }
105}
106
107// What applying it would change now: the mods that flip, and the commands that run.
108export function plan(preset: Preset, mods: readonly { id: string; enabled: boolean }[]) {
109  const current = new Map(mods.map(m => [m.id, m.enabled]))
110  const flips = Object.entries(preset.mods)
111    .filter(([id, want]) => current.has(id) && current.get(id) !== (want === 'on'))
112    .map(([id, want]) => ({ id, on: want === 'on' }))
113  const missing = Object.keys(preset.mods).filter(id => !current.has(id))
114  return { flips, steps: preset.steps.filter(s => s.enabled), missing }
115}
116
117// One line on what a preset does, for a card.
118export function summary(preset: Preset): string {
119  const on = Object.values(preset.mods).filter(s => s === 'on').length
120  const off = Object.values(preset.mods).filter(s => s === 'off').length
121  const steps = preset.steps.filter(s => s.enabled)
122  return [
123    on || off ? `${on} on${off ? `, ${off} off` : ''}` : '',
124    steps.length ? `runs ${steps.map(s => `/${s.command}${s.args ? ` ${s.args}` : ''}`).join(', ')}` : '',
125    preset.pins ? `${preset.pins.length} pinned` : '',
126  ]
127    .filter(Boolean)
128    .join(' · ') || 'empty: add mods or commands'
129}
130
131// "0:42", "4:05", "58 min" for the cache countdown.
132export function countdown(ms: number): string {
133  const s = Math.max(0, Math.ceil(ms / 1000))
134  if (s >= 600) return `${Math.round(s / 60)} min`
135  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
136}
137
types/index.d.ts 69 lines
1// A view of the mod list; shared/view.ts holds the same shape with its logic.
2export type View = { query: string; filter: 'all' | 'on' | 'off' | 'pinned' | 'commands' | 'changed' | 'new'; sort: 'status' | 'name' | 'newest' | 'commands'; source: string }
3
4// A preset and what goes in one; shared/presets.ts holds the same shapes with their logic.
5export type PresetStep = { command: string; args: string; enabled: boolean }
6export type Preset = {
7  id: string
8  name: string
9  note: string
10  mods: Record<string, 'on' | 'off'>
11  steps: PresetStep[]
12  pins: string[] | null
13  by: 'you' | 'claude'
14  updatedAt: number
15}
16export type RecentCommand = { command: string; args: string; at: number }
17
18// The prompt cache, as the last main-thread request left it.
19export type CacheState = { at: number; ttlMs: number; isLearned: boolean } | null
20
21// One installed plugin (a mod or a classic plugin) as the hub and the pane show it.
22export type ModRow = {
23  id: string // name@marketplace
24  name: string
25  marketplace: string
26  version: string
27  description: string
28  enabled: boolean
29  installedAt: string
30  commands: { name: string; description: string }[]
31  isSelf: boolean // mod-hub itself: never turned off from here
32}
33
34// What the hub web app is told, and what the band and the pane draw from.
35export type HubState = {
36  generatedAt: number
37  claudeVersion?: string
38  mods: ModRow[]
39  // Commands not from a plugin: Claude Code's own, the person's, MCP prompts. For the palette.
40  otherCommands: { name: string; description: string; source: string }[]
41  pins: string[] // command names shown as buttons in the bar
42  pending: string[] // plugin ids turned on or off since the last reload
43  presets: Preset[]
44  recent: RecentCommand[] // commands the person ran lately, newest first
45  cache: CacheState
46}
47
48declare module 'claude-code' {
49  interface PluginState {
50    'mod-hub': {
51      state: HubState | null
52      hubUrl: string // '' until the hub server answers
53      note: string // the last thing that went wrong, shown in the pane
54      view: View // search, filter, sort and marketplace; kept across sessions
55      compact: boolean // one line per mod instead of a card
56      clickMode: 'run' | 'copy' // what pressing a command does, in the pane and the bar
57      page: number
58      busy: string // the plugin id being turned on or off, '' when none
59      tab: 'mods' | 'presets' // the pane's two views
60      editing: string // the preset id open in the editor, '' when none
61      confirmDelete: string // the preset id whose Delete was pressed once
62      draftName: string // the name typed for a new preset
63      recent: RecentCommand[] // kept across a reload, which empties the module's own variables
64      cacheAt: number // when the last main-thread request finished; 0 before any
65      beat: number // moves once a second while the cache is warm, so the countdown redraws
66    }
67  }
68}
69