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

Personal agent plugin marketplace by kadragon.
Three plugins:
dev — development| Skill | Description | Claude Code | Codex |
|---|---|---|---|
harness-init | Bootstraps agent infrastructure (AGENTS.md, docs/, hooks) | ✅ | ❌ |
task-review | Orchestrates Claude/Antigravity/Codex reviewers, merges | ✅ | ❌ |
harness-curate | Mines transcripts to manage harness assets | ✅ | ❌ |
repo-dependabot | Bulk Dependabot PR operations | ✅ | ⚠️ |
Commands:
| Command | Description | Claude Code | Codex |
|---|---|---|---|
/security-overview | Scans GitHub security alerts (Dependabot, Code Scanning, Secret Scanning) across owned repos, writes per-repo plan.md | ✅ | ❌ |
prod — document authoring| Skill | Description | Claude Code | Codex |
|---|---|---|---|
hwpx | Korean HWPX document creation and editing | ✅ | ✅ |
persona-debate | Structured debate among Korean personas | ✅ | ⚠️ |
mods — Claude Code interface modsClaude 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.
| Mod | Description | Setting |
|---|---|---|
status-band | Two 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.
Migrating from
toolkit@kadragon? The former single plugin is now split intodev@kadragonandprod@kadragon. Remove the old plugin (claude plugin uninstall toolkit@kadragon) and install both below. Also update any SessionStart hook that referencedtoolkit:harness-maintenance— maintenance now runs inside thedevplugin's single SessionStart dispatcher (hooks/session-start/run.sh).
# 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 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 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.
gh) — gh auth loginMIT
hooks/register.ts 12 lines1import 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}
12hooks/band.tsx 30 lines1import { 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}
30hooks/status-band/register.tsx 492 lines1import { 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}
492hooks/status-band/count.ts 97 lines1// 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}
97hooks/status-band/format.ts 182 lines1// 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))
182types/index.d.ts 25 lines1export 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