SLOPSHOPPER

mods

Claude Code interface mods by kadragon. Claude Code only; built on the early-access function-hooks API.

newbandprocesstimer
★ 1v0.3.0MITupdated 2026-10-16kadragon/agent-toolkit/mods
A shopper browsing a rack in a slop shop
README

kadragon/agent-toolkit

Personal agent plugin marketplace by kadragon.

Three plugins:

dev — development

SkillDescriptionClaude CodeCodex
harness-initBootstraps agent infrastructure (AGENTS.md, docs/, hooks)✅❌
task-reviewOrchestrates Claude/Antigravity/Codex reviewers, merges✅❌
harness-curateMines transcripts to manage harness assets✅❌
repo-dependabotBulk Dependabot PR operations✅⚠️

Commands:

CommandDescriptionClaude CodeCodex
/security-overviewScans GitHub security alerts (Dependabot, Code Scanning, Secret Scanning) across owned repos, writes per-repo plan.md✅❌

prod — document authoring

SkillDescriptionClaude CodeCodex
hwpxKorean HWPX document creation and editing✅✅
persona-debateStructured debate among Korean personas✅⚠️

mods — Claude Code interface mods

Claude Code only. Built on the early-access function-hooks API, so it needs a recent Claude Code build and may break on an update; dev and prod do not depend on it.

ModDescriptionSetting
status-bandTwo rows above the prompt, each figure in its own color: model and effort, directory and git branch, session time, prompt-cache time left, hook count and skill count with the size of the skill listing, then context fill and the 5-hour and 7-day rate limits with their resets. A figure the engine has no reading for is left out.Status band (on by default), Status band cache TTL (minutes) (60)

status-band shows what a statusLine command such as claude-hud (github.com/jarrodwatts/claude-hud, whose layout it follows) shows, so running both draws the same figures twice: remove the statusLine entry from your settings.json if you switch. The mod never edits your settings. It redraws once a minute and after each turn.

The cache figure is an estimate: the engine tells a mod neither the cache's lifetime nor what is left of it, so the band counts the setting's minutes down from the last main-thread response. Set it to 5 where your session runs on the short cache. The hook count is the hook handlers in your merged settings plus those of every enabled plugin, read off the files that declare them; hooks modules such as this one's are not counted. Both counts are read again after each turn. A branch name over 24 characters is cut so the figures after it stay on the row, and a blank row keeps the band off the line above it.

Installation

Migrating from toolkit@kadragon? The former single plugin is now split into dev@kadragon and prod@kadragon. Remove the old plugin (claude plugin uninstall toolkit@kadragon) and install both below. Also update any SessionStart hook that referenced toolkit:harness-maintenance — maintenance now runs inside the dev plugin's single SessionStart dispatcher (hooks/session-start/run.sh).

npx skills

# All skills
npx skills add kadragon/agent-toolkit

# Specific skills
npx skills add kadragon/agent-toolkit --skill hwpx
npx skills add kadragon/agent-toolkit --skill persona-debate
npx skills add kadragon/agent-toolkit --skill task-review
npx skills add kadragon/agent-toolkit --skill task-review-cycle  # required — task-review only forwards to it
npx skills add kadragon/agent-toolkit --skill harness-init

Claude Code

claude plugin marketplace add kadragon/agent-toolkit
claude plugin install dev@kadragon
claude plugin install prod@kadragon
claude plugin install mods@kadragon   # optional

Via ~/.claude/settings.json:

{
  "enabledPlugins": {
    "dev@kadragon": true,
    "prod@kadragon": true
  },
  "extraKnownMarketplaces": {
    "kadragon": {
      "source": {
        "source": "github",
        "repo": "kadragon/agent-toolkit"
      },
      "autoUpdate": true
    }
  }
}

Codex

Codex uses .agents/plugins/marketplace.json and .codex-plugin/plugin.json manifests:

codex plugin marketplace add kadragon/agent-toolkit
codex plugin add dev@kadragon
codex plugin add prod@kadragon

Install only ✅/⚠️ skills from table above.

Prerequisites

License

MIT

Source 6 files
hooks/register.ts 12 lines
1import type { Register } from 'claude-code'
2
3import { register as band } from './band'
4import { register as statusBand } from './status-band/register'
5
6// The engine loads one hooks module per plugin, so every mod registers here.
7// Each is called by name: the loader refuses a callee it cannot trace to a file.
8export const register: Register = (on, options) => {
9  statusBand(on, options)
10  band(on, options)
11}
12
hooks/band.tsx 30 lines
1import { atom, read } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { statusRows } from './status-band/register'
5
6// The same value the mod writes: the loader reads an atom's key off the file
7// that uses it, so it is spelled again here.
8const statusBand = atom({ plugin: 'mods', key: 'statusBand' } as const, null)
9
10// The engine takes one hook per plugin on the band above the prompt, so a mod
11// that draws there hands its rows over here.
12// The status rows are two, under a blank one where a third row fits.
13const STATUS_ROWS = 2
14
15export const register: Register = (on, options) => {
16  // `$.state` outlives a reload, so a mod switched off mid-session still has its
17  // last value there: the option decides, not the value.
18  if (options.statusBandEnabled === false) return
19
20  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
21    if (e.props.hasSurvey || STATUS_ROWS > e.props.maxRows) return next(e)
22
23    const status = await read($, statusBand)
24
25    if (status === null) return next(e)
26
27    return statusRows($.ui.resolve(e), status, STATUS_ROWS < e.props.maxRows)
28  })
29}
30
hooks/status-band/register.tsx 492 lines
1import { atom, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { StatusBand } from '../../types'
5import {
6  enabledPlugins,
7  hookCommands,
8  hooksFileCommands,
9  installPaths,
10  manifestHooks,
11  under,
12} from './count'
13import { contextText, curateText, headlineSegments, levelOf, limitsSegments } from './format'
14import type { Segment } from './format'
15
16// Layout after the claude-hud status line (github.com/jarrodwatts/claude-hud).
17// The elapsed time and the reset countdowns read in minutes.
18const TICK_MS = 60_000
19const GIT_TIMEOUT_MS = 2000
20const MINUTE_MS = 60_000
21const DEFAULT_CACHE_TTL_MINUTES = 60
22// The dev plugin's session-start hook nudges about a due harness-curate run
23// once a day; the band keeps it in sight, asking the same script.
24const CURATE_PLUGIN = 'dev@kadragon'
25const CURATE_SCRIPT = 'skills/harness-curate/scripts/record_run.py'
26const CURATE_DUE_PREFIX = 'HARNESS-CURATE DUE'
27const CURATE_EVERY_MS = 10 * MINUTE_MS
28const CURATE_TIMEOUT_MS = 10_000
29// The surfaces the engine raises the band above the prompt on.
30const BAND_SURFACES: readonly string[] = ['terminal', 'desktop']
31
32const statusBand = atom({ plugin: 'mods', key: 'statusBand' } as const, null)
33
34// What one load of the module keeps between hooks.
35type Live = {
36  effort: StatusBand['effort']
37  // True from a main-loop step until the next read: that effort is the model's now.
38  isEffortFresh: boolean
39  tick?: { cancel: () => void }
40  asked: number
41  drawn: number
42  // Null once the session ended or the model changed: nothing is cached.
43  cacheExpiresAt?: number | null
44  cacheModel?: string
45  // The counts of what the session is set up with, as last read.
46  setup: Setup
47  // False until this load read or reset them: a reload keeps what is drawn.
48  hasSetup: boolean
49  // Raised by a reset, so a read that started before it is dropped.
50  setupEpoch: number
51  setupRead?: Promise<void>
52  // When the curate check last ran; unset, the next read runs it.
53  curateCheckedAt?: number
54  curateRead?: Promise<void>
55}
56
57type Setup = Pick<StatusBand, 'hooks' | 'skills' | 'curateDue'>
58
59// The handlers a plugin's default hooks file, its manifest and the files the
60// manifest names add; a file that is not there adds none.
61const pluginHooks = async ($: EngineInterface, root: string): Promise<number> => {
62  const { files, inline } = await $.fs
63    .read(under(root, '.claude-plugin/plugin.json'))
64    .then(manifestHooks)
65    .catch(() => manifestHooks('{}'))
66  const counts = await Promise.all(
67    files.map(file =>
68      $.fs
69        .read(under(root, file))
70        .then(hooksFileCommands)
71        .catch(() => 0),
72    ),
73  )
74
75  return counts.reduce((total, count) => total + count, inline)
76}
77
78// Where the installed-plugins file says each plugin's directory is.
79const readInstallPaths = async ($: EngineInterface): Promise<Record<string, string>> => {
80  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
81  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
82
83  const claudeDir = configDir ?? (home === undefined ? undefined : under(home, '.claude'))
84
85  if (claudeDir === undefined) throw new Error('no home directory')
86
87  return installPaths(
88    await $.fs.read(under(claudeDir, 'plugins/installed_plugins.json')),
89    await $.session.cwd(),
90  )
91}
92
93// Whether a harness-curate run is due here, judged as the session-start hook
94// judges it: only in a harness repo, by the installed dev plugin's own script.
95const readCurateDue = async ($: EngineInterface): Promise<boolean> => {
96  if (!enabledPlugins(await $.settings.read()).includes(CURATE_PLUGIN)) return false
97
98  const cwd = await $.session.cwd()
99
100  if (!(await $.fs.exists(under(cwd, 'AGENTS.md')))) return false
101
102  const root = (await readInstallPaths($))[CURATE_PLUGIN]
103
104  if (root === undefined) return false
105
106  const args = [under(root, CURATE_SCRIPT), '--check-due', '--project', cwd]
107  const init = { timeoutMs: CURATE_TIMEOUT_MS }
108  // As the hook does: `python3`, else `python` where only that is installed.
109  const { exitCode, stdout } = await $.process
110    .run(['python3', ...args], init)
111    .catch(() => $.process.run(['python', ...args], init))
112
113  return exitCode === 0 && stdout.startsWith(CURATE_DUE_PREFIX)
114}
115
116// The hook handlers in force: the settings' own and every enabled plugin's.
117// The engine lists none, so they are counted off the files that declare them.
118const countHooks = async ($: EngineInterface): Promise<number> => {
119  const settings = await $.settings.read()
120
121  if (settings.disableAllHooks === true) return 0
122
123  if (settings.allowManagedHooksOnly === true) {
124    return hookCommands((await $.settings.read({ source: 'policy' })).hooks)
125  }
126
127  const own = hookCommands(settings.hooks)
128  const enabled = enabledPlugins(settings)
129
130  if (enabled.length === 0) return own
131
132  const paths = await readInstallPaths($)
133  const counts = await Promise.all(
134    enabled.map(id => (paths[id] === undefined ? 0 : pluginHooks($, paths[id]))),
135  )
136
137  return counts.reduce((total, count) => total + count, own)
138}
139
140// The skill listing as /context counts it, estimated locally: no request is sent.
141const readSkills = async ($: EngineInterface): Promise<StatusBand['skills']> => {
142  const { context } = await $.session.usage({ breakdown: 'summary' })
143
144  const skills = context.breakdown?.skills
145
146  return skills === undefined ? undefined : { count: skills.totalSkills, tokens: skills.tokens }
147}
148
149// Reads the counts apart from the other figures, which must not wait for the
150// files: one read at a time, and a count that cannot be read keeps the last
151// one this session gave.
152const loadSetup = ($: EngineInterface, live: Live): void => {
153  if (live.setupRead !== undefined) return
154
155  const epoch = live.setupEpoch
156
157  live.setupRead = (async () => {
158    const [hooks, skills] = await Promise.all([
159      countHooks($).catch(() => undefined),
160      readSkills($).catch(() => undefined),
161    ])
162
163    if (epoch !== live.setupEpoch) return
164
165    const setup = { hooks: hooks ?? live.setup.hooks, skills: skills ?? live.setup.skills }
166
167    live.setup = { ...live.setup, ...setup }
168    live.hasSetup = true
169    await update($, statusBand, current => (current === null ? null : { ...current, ...setup }))
170  })()
171    .catch(() => undefined)
172    .finally(() => {
173      live.setupRead = undefined
174      // A reset came while this read ran: the counts are the new session's to read.
175      if (epoch !== live.setupEpoch) loadSetup($, live)
176    })
177}
178
179// The curate check spawns a scan, so it runs apart from the counts and at most
180// once in a while; between runs, or when a run fails, the last answer stands.
181const loadCurate = ($: EngineInterface, live: Live): void => {
182  if (live.curateRead !== undefined) return
183
184  const epoch = live.setupEpoch
185
186  live.curateRead = (async () => {
187    const now = await $.clock.now()
188
189    if (epoch !== live.setupEpoch) return
190    if (live.curateCheckedAt !== undefined && now - live.curateCheckedAt < CURATE_EVERY_MS) return
191
192    live.curateCheckedAt = now
193    const curateDue = await readCurateDue($).catch(() => undefined)
194
195    if (epoch !== live.setupEpoch || curateDue === undefined) return
196
197    live.setup = { ...live.setup, curateDue }
198    live.hasSetup = true
199    await update($, statusBand, current => (current === null ? null : { ...current, curateDue }))
200  })()
201    .catch(() => undefined)
202    .finally(() => {
203      live.curateRead = undefined
204      // A reset came while this read ran: the new session asks again.
205      if (epoch !== live.setupEpoch) loadCurate($, live)
206    })
207}
208
209// The counts were another session's: they are left out until read again.
210const resetSetup = (live: Live): void => {
211  live.setup = {}
212  live.hasSetup = true
213  live.setupEpoch += 1
214  // A run in the last session stamped its record: ask again.
215  live.curateCheckedAt = undefined
216}
217
218// Only a response that read or wrote the prompt cache renews it.
219const isCached = (usage: { cache_read_input_tokens: number; cache_creation_input_tokens: number } | null): boolean =>
220  usage !== null && usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0
221
222// The band is a convenience: a figure the engine cannot give is left out or
223// kept as last read, and nothing here may fail a hook.
224const refresh = async ($: EngineInterface, live: Live): Promise<void> => {
225  live.asked += 1
226  const order = live.asked
227
228  try {
229    const [model, cwd, usage, turns, now, git] = await Promise.all([
230      $.session.model().catch(() => ''),
231      $.session.cwd().catch(() => ''),
232      $.session.usage().catch(() => null),
233      $.session.turns().catch(() => null),
234      $.clock.now(),
235      $.process
236        .run(['git', 'branch', '--show-current'], { timeoutMs: GIT_TIMEOUT_MS })
237        .catch(() => null),
238    ])
239    // Git answering with no name (no repository, a detached HEAD) is a reading.
240    const branch = git === null ? null : git.exitCode === 0 ? git.stdout.trim() : ''
241
242    // What the write decided, kept for after it: the engine may run the
243    // function again, so it reads `live` and leaves it as it is.
244    const before = { effort: live.effort, isFresh: live.isEffortFresh, cache: live.cacheExpiresAt }
245    let decided: { effort: StatusBand['effort']; isForfeit: boolean } | undefined
246
247    await update($, statusBand, current => {
248      decided = undefined
249
250      // A read that started earlier and ended later must not undo a newer one.
251      if (order < live.drawn) return current
252
253      live.drawn = order
254      const name = model === '' ? (current?.model ?? '') : model
255      let effort = before.effort
256
257      if (before.isFresh) {
258        // The step that reported it ran under this model.
259      } else if (current !== null && current.model !== name) {
260        // The effort was the last model's; the next step reports this one's.
261        effort = undefined
262      } else if (effort === undefined) {
263        // A reload starts the module over; the session's value is still good.
264        effort = current?.effort
265      }
266
267      // The cache was the model's that made it: a switch forfeits it.
268      const cacheModel = live.cacheModel ?? current?.cacheModel
269      const isForfeit = cacheModel !== undefined && name !== '' && cacheModel !== name
270      const isCacheGone = isForfeit || live.cacheExpiresAt === null
271
272      decided = { effort, isForfeit }
273
274      return {
275        model: name,
276        effort,
277        cwd: cwd === '' ? (current?.cwd ?? '') : cwd,
278        branch: branch === null ? current?.branch : branch === '' ? undefined : branch,
279        startedAt: usage?.startedAt ?? current?.startedAt,
280        now,
281        // Before the first prompt nothing is in the window: that 0 is a reading.
282        contextPercent:
283          usage === null
284            ? current?.contextPercent
285            : (usage.context.percent ?? (turns === 0 ? 0 : undefined)),
286        rateLimits: usage?.rateLimits ?? current?.rateLimits ?? [],
287        cacheExpiresAt: isCacheGone
288          ? undefined
289          : (live.cacheExpiresAt ?? current?.cacheExpiresAt),
290        cacheModel: isCacheGone ? undefined : cacheModel,
291        hooks: live.hasSetup ? live.setup.hooks : current?.hooks,
292        skills: live.hasSetup ? live.setup.skills : current?.skills,
293        curateDue: live.hasSetup ? live.setup.curateDue : current?.curateDue,
294      }
295    })
296
297    if (decided !== undefined) {
298      // A step that reported meanwhile is newer than what this read decided.
299      if (live.effort === before.effort) live.effort = decided.effort
300      if (before.isFresh) live.isEffortFresh = false
301
302      if (decided.isForfeit && live.cacheExpiresAt === before.cache) {
303        live.cacheExpiresAt = null
304        live.cacheModel = undefined
305      }
306    }
307  } catch {
308    // Keeps what was last drawn.
309  }
310}
311
312// Reads the figures and keeps the minute tick, where a surface draws the band.
313const watch = async ($: EngineInterface, live: Live): Promise<void> => {
314  try {
315    const surfaces = await $.session.surfaces()
316
317    if (!surfaces.some(surface => BAND_SURFACES.includes(surface))) {
318      live.tick?.cancel()
319      live.tick = undefined
320
321      return
322    }
323
324    live.tick ??= $.clock.every(TICK_MS, () => void refresh($, live))
325    loadSetup($, live)
326    loadCurate($, live)
327    await refresh($, live)
328  } catch {
329    // Nothing to draw on.
330  }
331}
332
333export const register: Register = (on, options) => {
334  if (options.statusBandEnabled === false) return
335
336  const ttl = options.statusBandCacheTtlMinutes
337  const cacheTtlMs =
338    (typeof ttl === 'number' && ttl > 0 ? ttl : DEFAULT_CACHE_TTL_MINUTES) * MINUTE_MS
339  const live: Live = {
340    effort: undefined,
341    isEffortFresh: false,
342    asked: 0,
343    drawn: 0,
344    setup: {},
345    hasSetup: false,
346    setupEpoch: 0,
347  }
348
349  // Neither hook waits for the read: a slow git must not hold the session.
350  on('session.start', async ($, e, next) => {
351    const result = await next(e)
352
353    resetSetup(live)
354    void watch($, live)
355
356    return result
357  })
358
359  // Raised after each main-thread turn too, which may have switched the branch;
360  // a surface that attached after the start gets its tick here.
361  on('session.measure', ($, e, next) => {
362    void watch($, live)
363
364    return next(e)
365  })
366
367  // The band follows the surfaces: one that attaches gets it at once, and the
368  // tick stops when the last one that draws it leaves.
369  on('session.attach', async ($, e, next) => {
370    const result = await next(e)
371
372    void watch($, live)
373
374    return result
375  })
376
377  on('session.detach', async ($, e, next) => {
378    const result = await next(e)
379
380    void watch($, live)
381
382    return result
383  })
384
385  // A `/clear` empties the window and starts the timer over; no measure follows it.
386  // Whatever ends the session, the conversation that follows has its own cache
387  // and its own counts, and where the process goes on the band shows it at once.
388  on('session.end', async ($, e, next) => {
389    const result = await next(e)
390
391    live.cacheExpiresAt = null
392    live.cacheModel = undefined
393    resetSetup(live)
394    void watch($, live)
395
396    return result
397  })
398
399  // The request is passed on untouched: only its effort is read, and a
400  // response of the main loop renews the prompt cache from when it was sent.
401  on('turn.step', async function* ($, e, next) {
402    if (e.agentId === undefined) live.isEffortFresh = true
403
404    if (e.agentId === undefined && e.effort !== live.effort) {
405      const effort = e.effort
406
407      live.effort = effort
408      await update($, statusBand, current =>
409        current === null ? null : { ...current, effort },
410      ).catch(() => undefined)
411    }
412
413    const sentAt = e.agentId === undefined ? $.clock.now().catch(() => undefined) : undefined
414    const result = yield* next(e)
415
416    // The step does not wait for the band: the countdown is written behind it.
417    if (sentAt !== undefined && isCached(result.usage)) {
418      void sentAt
419        .then(async at => {
420          if (at === undefined) return
421
422          const cacheExpiresAt = at + cacheTtlMs
423          const model = await $.session.model().catch(() => '')
424          const cacheModel = model === '' ? undefined : model
425
426          live.cacheExpiresAt = cacheExpiresAt
427          live.cacheModel = cacheModel
428          await update($, statusBand, current =>
429            current === null ? null : { ...current, cacheExpiresAt, cacheModel },
430          )
431        })
432        .catch(() => undefined)
433    }
434
435    return result
436  })
437}
438
439type BandElements = Pick<Elements['terminal'], 'Box' | 'Text'>
440
441const runs = ({ Text }: BandElements, segments: Segment[]): RenderElement[] =>
442  segments.map((segment, index) => (
443    <Text
444      key={`run-${index}`}
445      color={segment.color}
446      bold={segment.isBold}
447      dimColor={segment.isDim}
448    >
449      {segment.text}
450    </Text>
451  ))
452
453// This mod's rows of the band above the prompt, under a blank row that keeps
454// them off the line above where the band has a row to spare. `hooks/band.tsx`
455// draws them: the engine takes one band hook per plugin, and `$` stays in that
456// hook's file.
457export const statusRows = (
458  elements: BandElements,
459  status: StatusBand,
460  hasMargin: boolean,
461): RenderElement => {
462  const { Box, Text } = elements
463  const context = contextText(status)
464  const limits = limitsSegments(status)
465  const curate = curateText(status)
466
467  return (
468    <Box key="status-band" flexDirection="column" marginTop={hasMargin ? 1 : 0}>
469      <Text wrap="truncate-end">{runs(elements, headlineSegments(status))}</Text>
470      {context === '' && limits.length === 0 && curate === '' ? null : (
471        <Box>
472          {context === '' ? null : <Text color={levelOf(status.contextPercent)}>{context}</Text>}
473          {limits.length === 0 ? null : (
474            <Text wrap="truncate-end">
475              {context === '' ? null : <Text dimColor> │ </Text>}
476              {runs(elements, limits)}
477            </Text>
478          )}
479          {curate === '' ? null : (
480            <Box>
481              {context === '' && limits.length === 0 ? null : <Text dimColor> │ </Text>}
482              <Text color="warning" wrap="truncate-end">
483                {curate}
484              </Text>
485            </Box>
486          )}
487        </Box>
488      )}
489    </Box>
490  )
491}
492
hooks/status-band/count.ts 97 lines
1// Pure hook counting: no engine types, so it runs and tests without `$`.
2
3const DEFAULT_HOOKS_FILE = 'hooks/hooks.json'
4
5const isRecord = (value: unknown): value is Record<string, unknown> =>
6  typeof value === 'object' && value !== null && !Array.isArray(value)
7
8// The handlers under a settings.json `hooks` value, of whatever type: a
9// matcher that runs two counts as two.
10export const hookCommands = (hooks: unknown): number => {
11  if (!isRecord(hooks)) return 0
12
13  return Object.values(hooks).reduce<number>((total, groups) => {
14    if (!Array.isArray(groups)) return total
15
16    return groups.reduce<number>(
17      (sum, group) => sum + (isRecord(group) && Array.isArray(group.hooks) ? group.hooks.length : 0),
18      total,
19    )
20  }, 0)
21}
22
23// A hooks file wraps the same value in `hooks`; a function-hooks file has none.
24export const hooksFileCommands = (text: string): number => {
25  const file: unknown = JSON.parse(text)
26
27  return isRecord(file) ? hookCommands(file.hooks) : 0
28}
29
30// The plugins the settings switch on, by their `<name>@<marketplace>` id.
31export const enabledPlugins = (settings: Record<string, unknown>): string[] =>
32  isRecord(settings.enabledPlugins)
33    ? Object.entries(settings.enabledPlugins).flatMap(([id, isOn]) => (isOn === true ? [id] : []))
34    : []
35
36// How much of `cwd` a project path covers, in characters; 0 when it is not
37// that directory or one beneath it. Either separator ends a segment.
38const covered = (projectPath: unknown, cwd: string): number => {
39  if (typeof projectPath !== 'string') return 0
40
41  const root = projectPath.replace(/[\\/]+$/, '')
42  const isWithin = cwd === root || (cwd.startsWith(root) && /[\\/]/.test(cwd.charAt(root.length)))
43
44  return isWithin ? root.length + 1 : 0
45}
46
47// Where the installed-plugins file says each plugin's directory is. A plugin
48// installed more than once is read from the install made for the nearest
49// project around `cwd`, else the user's own, else the first listed.
50export const installPaths = (text: string, cwd: string): Record<string, string> => {
51  const file: unknown = JSON.parse(text)
52  const plugins = isRecord(file) && isRecord(file.plugins) ? file.plugins : {}
53
54  return Object.fromEntries(
55    Object.entries(plugins).flatMap(([id, listed]) => {
56      const installs = (Array.isArray(listed) ? listed : []).filter(
57        (install): install is Record<string, unknown> & { installPath: string } =>
58          isRecord(install) && typeof install.installPath === 'string',
59      )
60      const nearest = installs.reduce<(typeof installs)[number] | undefined>(
61        (best, candidate) =>
62          covered(candidate.projectPath, cwd) > covered(best?.projectPath, cwd) ? candidate : best,
63        undefined,
64      )
65      const install =
66        nearest ?? installs.find(candidate => candidate.projectPath === undefined) ?? installs[0]
67
68      return install === undefined ? [] : [[id, install.installPath]]
69    }),
70  )
71}
72
73const relative = (path: string): string => path.replace(/^\.\//, '')
74
75// `path` beneath `base`, in the separator `base` is written with: a Windows
76// home or install path keeps its backslashes all the way down.
77export const under = (base: string, path: string): string =>
78  base.includes('\\') && !base.includes('/')
79    ? `${base}\\${path.replace(/\//g, '\\')}`
80    : `${base}/${path}`
81
82// What a plugin's manifest adds to its default hooks file: the files it names,
83// and the hooks it carries inline. The engine loads both together.
84export const manifestHooks = (text: string): { files: string[]; inline: number } => {
85  const manifest: unknown = JSON.parse(text)
86  const named = isRecord(manifest) ? manifest.hooks : undefined
87  const files = new Set([DEFAULT_HOOKS_FILE])
88  let inline = 0
89
90  for (const entry of Array.isArray(named) ? named : [named]) {
91    if (typeof entry === 'string') files.add(relative(entry))
92    else if (isRecord(entry)) inline += hookCommands(isRecord(entry.hooks) ? entry.hooks : entry)
93  }
94
95  return { files: [...files], inline }
96}
97
hooks/status-band/format.ts 182 lines
1// Pure status text: no engine types, so it runs and tests without `$`.
2
3import type { StatusBand } from '../../types'
4
5const BAR_CELLS = 10
6// A longer branch name is cut, so it does not push the figures after it off the row.
7const BRANCH_CELLS = 24
8const MINUTE_MS = 60_000
9const HOUR_MS = 60 * MINUTE_MS
10const DAY_MS = 24 * HOUR_MS
11const SEPARATOR = ' │ '
12const EFFORT_GLYPHS: Record<string, string> = {
13  low: '○',
14  medium: '◔',
15  high: '◑',
16  xhigh: '◕',
17  max: '●',
18}
19// The windows the band names, in the order it draws them.
20const WINDOWS: readonly (readonly [kind: string, label: string])[] = [
21  ['five_hour', '5h'],
22  ['seven_day', '7d'],
23]
24
25export type Level = 'success' | 'warning' | 'error'
26
27// One run of a row drawn in its own color; `color` is a theme key.
28export type Segment = { text: string; color?: string; isBold?: boolean; isDim?: boolean }
29
30// `claude-opus-5-5` reads as `Opus 5.5`; a name of any other shape is kept.
31export const modelLabel = (model: string): string => {
32  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[[^\]]+\])?$/.exec(model.trim())
33
34  if (match === null) return model.trim()
35
36  const [, family = '', major = '', minor, suffix] = match
37  const version = minor === undefined ? major : `${major}.${minor}`
38
39  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version}${suffix ?? ''}`
40}
41
42export const clampPercent = (percent: number | undefined): number =>
43  percent === undefined || Number.isNaN(percent) ? 0 : Math.min(100, Math.max(0, percent))
44
45export const bar = (percent: number | undefined): string => {
46  const filled = Math.round((clampPercent(percent) / 100) * BAR_CELLS)
47
48  return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
49}
50
51export const levelOf = (percent: number | undefined): Level => {
52  const used = clampPercent(percent)
53
54  if (used >= 90) return 'error'
55
56  return used >= 70 ? 'warning' : 'success'
57}
58
59// The two largest units, so a week reads `6d 7h` and an hour `2h 4m`.
60export const duration = (ms: number): string => {
61  if (ms < MINUTE_MS) return '<1m'
62
63  const days = Math.floor(ms / DAY_MS)
64  const hours = Math.floor((ms % DAY_MS) / HOUR_MS)
65  const minutes = Math.floor((ms % HOUR_MS) / MINUTE_MS)
66
67  if (days > 0) return `${days}d ${hours}h`
68
69  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
70}
71
72const modelSegment = (status: StatusBand): string => {
73  const name = modelLabel(status.model)
74
75  if (status.effort === undefined) return `[${name}]`
76
77  const glyph = EFFORT_GLYPHS[String(status.effort)]
78
79  return glyph === undefined ? `[${name} ${status.effort}]` : `[${name} ${glyph} ${status.effort}]`
80}
81
82const basename = (path: string): string => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
83
84const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`
85
86// `4200` reads as `4.2k`; under a thousand the count is kept.
87export const tokens = (count: number): string =>
88  count < 1000 ? String(Math.round(count)) : `${(count / 1000).toFixed(1)}k`
89
90// What is left of the prompt cache, counted from the last response.
91export const cacheText = (status: StatusBand): string => {
92  if (status.cacheExpiresAt === undefined) return ''
93
94  const left = status.cacheExpiresAt - status.now
95
96  return left <= 0 ? 'cache expired' : `cache ${duration(left)}`
97}
98
99const joined = (groups: Segment[][], separator: string): Segment[] =>
100  groups
101    .filter(group => group.length > 0)
102    .flatMap((group, index) =>
103      index === 0 ? group : [{ text: separator, isDim: true }, ...group],
104    )
105
106// A figure the engine has no reading for is left out, with its separator.
107export const headlineSegments = (status: StatusBand): Segment[] => {
108  const dir = basename(status.cwd)
109  const cache = cacheText(status)
110  const place: Segment[] = dir === '' ? [] : [{ text: dir, color: 'suggestion', isBold: true }]
111
112  if (status.branch !== undefined) {
113    const branch =
114      status.branch.length > BRANCH_CELLS
115        ? `${status.branch.slice(0, BRANCH_CELLS - 1)}…`
116        : status.branch
117
118    place.push({ text: `${dir === '' ? '' : ' '}git:(${branch})`, color: 'autoAccept' })
119  }
120
121  return joined(
122    [
123      status.model === '' ? [] : [{ text: modelSegment(status), color: 'claude', isBold: true }],
124      place,
125      status.startedAt === undefined
126        ? []
127        : [{ text: `⏱️  ${duration(status.now - status.startedAt)}` }],
128      cache === ''
129        ? []
130        : [{ text: cache, color: cache === 'cache expired' ? 'warning' : 'planMode' }],
131      status.hooks === undefined
132        ? []
133        : [{ text: plural(status.hooks, 'hook'), color: 'permission' }],
134      status.skills === undefined
135        ? []
136        : [
137            { text: plural(status.skills.count, 'skill'), color: 'remember' },
138            { text: ` (${tokens(status.skills.tokens)})`, isDim: true },
139          ],
140    ],
141    SEPARATOR,
142  )
143}
144
145const textOf = (segments: Segment[]): string => segments.map(segment => segment.text).join('')
146
147export const headline = (status: StatusBand): string => textOf(headlineSegments(status))
148
149// A fill the engine has no reading for is left out, never drawn as 0%.
150export const contextText = (status: StatusBand): string =>
151  status.contextPercent === undefined
152    ? ''
153    : `Context ${bar(status.contextPercent)} ${Math.round(clampPercent(status.contextPercent))}%`
154
155// A window the engine has no reading for is left out, never drawn as 0%.
156export const limitsSegments = (status: StatusBand): Segment[] =>
157  joined(
158    WINDOWS.map(([kind, label]): Segment[] => {
159      const limit = status.rateLimits.find(candidate => candidate.kind === kind)
160
161      if (limit === undefined) return []
162
163      const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
164      const left = resetsAt - status.now
165      const used: Segment[] = [
166        { text: `${label}: `, isDim: true },
167        { text: `${Math.round(limit.percentUsed)}%`, color: levelOf(limit.percentUsed) },
168      ]
169
170      return Number.isNaN(left) || left <= 0
171        ? used
172        : [...used, { text: ` (${duration(left)})`, isDim: true }]
173    }),
174    ' | ',
175  )
176
177// A harness-curate run the dev plugin judges due; nothing otherwise.
178export const curateText = (status: StatusBand): string =>
179  status.curateDue === true ? 'harness-curate due' : ''
180
181export const limitsText = (status: StatusBand): string => textOf(limitsSegments(status))
182
types/index.d.ts 25 lines
1export type StatusBand = {
2  model: string
3  effort?: string | number
4  cwd: string
5  branch?: string
6  startedAt?: number
7  now: number
8  contextPercent?: number
9  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
10  // When the prompt cache lapses unless a response renews it first.
11  cacheExpiresAt?: number
12  // The model that cache was made under: a switch forfeits it.
13  cacheModel?: string
14  hooks?: number
15  skills?: { count: number; tokens: number }
16  // A harness-curate run is due in this repo, as the dev plugin judges it.
17  curateDue?: boolean
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    mods: { statusBand: StatusBand | null }
23  }
24}
25