Claude Control panes: SETUP (everything that shapes a session, hidden rules flagged), USAGE (1/7/30-day model, effort and delegation mix with rule targets) and…

Mods for Claude Code: small panes that show what your agents do. They never block or rewrite a tool call; at most they warn.
Needs Claude Code 2.1.287 or later (function-hook mods, panes, Svg element).
| Mod | Command | What it shows | ||||
|---|---|---|---|---|---|---|
factory | /factory, /factory <title or id> | Dashboard of every conversation (model, effort, context %, cost today, running subagents, PR/MR and CI, waiting on you). Click one for its agent tree: main thread, each subagent and Workflow node with model, effort, tokens and estimated cost, the bill per model and per turn, refused tool calls, the session log and warnings (subagent without an explicit model, Opus doing read-only search, big context without handoff/RESUME.md, timer tools). | ||||
control | `/control [setup\ | usage\ | practices\ | history] [scan]` | SETUP: everything that shapes a session (settings, env, permissions, global and repo CLAUDE.md, agents, skills, hook scripts, mods, scheduled tasks, MCP servers, plugins, memory), with hidden rules and any blocker (hook, deny rule, mod deny path) at the top. USAGE: 1/7/30-day model, effort and delegation mix from the transcripts, each metric against a target. PRACTICES and HISTORY: your rules file, followed or not, and its version history. | |
ci | /ci [refresh], `/ci route <repo\ | session\ | here> <backend-id>, /ci clear <target>` | CI routing: the CI backends from your config (GitHub Actions, GitLab.com shared runners, a self-hosted runner, VM runners still coming), whether each is usable now (token present yes/no, runners online, running/pending jobs, Actions per routed repo), and which repo or conversation uses which. A lead conversation or you set the route; each conversation sees its route in the status line and as a short note in its system prompt. | ||
scenarios | `/scenarios [S1..] [prompt\ | agent\ | workflow\ | frontmatter\ | env]` | Scénarios d'agents: presets of roles -> model, effort, unit (session, sub-conversation, Workflow agent(), Agent subagent) and max parallel (S1 Standard V6, S2 Économe, S3 Qualité max, S4 Nuit, plus yours from a JSON file). For each role, the exact settings (Agent call, agent() opts, agent-file frontmatter, env vars) and the documented limits, each with its doc URL and "non vérifié" where the docs say nothing; a Copy button for a ready-to-paste snippet. Warn-only toast when an Agent call names no model. |
guide | /mods-guide [onglet ou section] | Mode d'emploi des mods, in French: tabs (Sommaire, Bases, Événements, Interface, Configuration, Installer et activer, Tester, Écrire un mod pas à pas, Référence API, Nos mods, FAQ), foldable categories, and one card per mod of this repo built live from its plugin.json and hooks module. Same content in MODE-EMPLOI.md. |
factory and control read local files only (transcripts under ~/.claude/projects, the desktop app's session files, your settings) through a Python scanner (standard library) and draw the result. Dollar figures are estimates from token counts and the rate table in each mod's config.
What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.
| Mod | Verdict | Why |
|---|---|---|
| factory | keep | The only view of the agent tree with models, efforts and cost. Next: feed it from hook events (agent.spawn, turn.start) instead of scanning files. |
| control | keep | The "nothing hidden" proof: every rule source, env variable and blocker in one place. Its metrics and practices still follow the V5 rules and are being rewritten for V6. |
| coordinator | merge into factory | Linked sub-conversations remove most lane collisions; only the "green PR not merged" and "waiting on you" rows are worth keeping, as a strip in factory. |
| context band (cc-control band, context-meter) | merge into a V6 band | Small and correct: context size against the hand-off threshold, read-only. Not written yet. |
| cc-control guards | drop | They refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out. |
| ci | new (Oct 2026) | Which CI backend a repo or conversation should use, and whether it works right now, in one place instead of in rules text. |
| scenarios | new (Oct 2026) | Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts. |
| guide | new (Oct 2026) | How mods work and how to write one, plus a card per mod that reads the mod itself. |
| cost-tracker, activity-pane, launchpad, night | drop | Wrong numbers for new model ids, superseded by factory, or built on timer wake-ups. |
| V4 pages and watcher | drop | Dead since early Oct, replaced by factory and the app's own agent view. |
Copy or symlink a mod folder to ~/.claude/mods/<name>:
git clone https://github.com/julian-passebecq/claude-mod.git
# copy
cp -r claude-mod/mods/factory ~/.claude/mods/factory
# or symlink (Windows: mklink /D, as admin or with developer mode)
ln -s "$PWD/claude-mod/mods/factory" ~/.claude/mods/factory
Then load it, either for every session with the environment (in ~/.claude/settings.json under env, folders separated by ; on Windows, : elsewhere):
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/factory;~/.claude/mods/control;~/.claude/mods/ci;~/.claude/mods/scenarios;~/.claude/mods/guide"
or as a dev-mod in the desktop app for one session. Python 3 must be on PATH (the scanners); gh / glab are used, when present, for PR/MR and CI state.
Each mod has a config file next to it (factory.config.json, config.json): rates, thresholds, colors, labels and paths are set there, nothing is hard-coded. factory also deep-merges ~/.claude/effort-board/factory.config.json if you have one. In control/config.json, set paths.projects_root (the folder that holds your repos) and the optional doc paths.
cp claude-mod/mods/ci/ci.config.example.json ~/.claude/ci.config.json # then fill the <PLACEHOLDERS>
python -I ~/.claude/mods/ci/scanner/ci.py show --check # same data as /ci, from any shell
python -I ~/.claude/mods/ci/scanner/ci.py route ~/code/my-repo gitlab-local --by lead
--config, then $CI_MOD_CONFIG, then ~/.claude/ci.config.json, then ci.config.json next to the mod, then the shipped example. Each backend has id, label, kind (github | gitlab), host, runner_tag, status (active | coming-soon | disabled) and notes; optional group, concurrent, minutes_month, public_only, check_runners, pending_projects. A value still in <angle brackets> counts as unset. Labels and colors are in labels.gh auth status, glab auth status, or the env variable names listed under hosts); a token value is never printed or stored. With a GitLab token: runners with the backend's tag online/offline and their running jobs (pending jobs for the projects in pending_projects). With gh: Actions availability for the repos routed to a GitHub backend. Missing tools or tokens show as unknown. Results are cached (status_cache, cache_minutes); /ci refresh checks now.routing_file (default ~/.claude/ci-routing.json), one entry per repo path or session id with backend, who set it and when. A session route wins over a repo route; a worktree inside a routed repo follows the repo. status_line and prompt_note in the config turn the status-line entry and the system-prompt note off.PATH; gh and glab are optional.deny, no rewriting of tool calls or prompts.ci also writes its routing file, on /ci route and /ci clear).gh / glab for PR state and the GitLab API for runner status (ci).python -I -m unittest discover -s mods/control/tests -p "test_*.py"
python -I -m unittest discover -s mods/ci/tests -p "test_*.py"
node --test mods/factory/tests/logic.test.mjs mods/scenarios/tests/logic.test.mjs mods/guide/tests/guide.test.mjs
node mods/guide/scripts/build.mjs # regenerate MODE-EMPLOI.md (table of contents, Nos mods cards)
# hook and pane tests, with the Claude Code test kit:
claude plugin test mods/factory
claude plugin test mods/control
claude plugin test mods/ci
claude plugin test mods/scenarios
claude plugin test mods/guide
# before every push (public repo): no private paths, tokens, e-mails or transcripts
python -I tools/confidentiality_scan.py .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is ignored since Claude Code 2.1.287 (mods are on by default, overview). GitHub Actions (.github/workflows/tests.yml) runs the Python and node tests and the confidentiality scan; the test-kit suites need the claude CLI and run locally.
control's reference-window test replays the author's own transcripts and only runs with CONTROL_REFERENCE=1.
MIT
hooks/register.tsx 371 lines1// control mod: three panes over the JSON that scanner/control_scan.py writes (config.json says where).
2// SETUP everything that shapes a session, hidden rules first
3// USAGE 1/7/30-day and 2-9 Oct model, effort and delegation mix, each metric against its rule target
4// PRACTICES our way of working from ~/.claude/CLAUDE.md (V5) and the 9 Oct audit, followed or not; docs/PRACTICES.md
5// HISTORY docs/HISTORY.md: every rule version V1-V4.9 and why it was dropped
6// /control opens all four (or `/control usage`), `/control scan` rescans first. Drawing never runs the scanner:
7// scans start from the command or a Rescan button, on a timer so they outlive the dispatch.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, ResolveInput } from 'claude-code'
10
11import type { Metric, PracticesData, SetupData, SetupItem, UsageData, UsageWindow } from '../types'
12import {
13 PANES,
14 ageText,
15 big,
16 cut,
17 expandHome,
18 isStale,
19 kb,
20 metricValue,
21 parseArgs,
22 pct,
23 stateColor,
24 stateMark,
25 usd,
26} from './view'
27
28const tick = atom({ plugin: 'control', key: 'tick' } as const, 0)
29const scanning = atom({ plugin: 'control', key: 'scanning' } as const, false)
30const error = atom({ plugin: 'control', key: 'error' } as const, '')
31const windowId = atom({ plugin: 'control', key: 'window' } as const, '7d')
32const section = atom({ plugin: 'control', key: 'section' } as const, '')
33const showReference = atom({ plugin: 'control', key: 'showReference' } as const, false)
34
35type Config = { paths: { out_dir: string }; scan: { stale_minutes: number; windows: { id: string; label: string }[] } }
36
37const safe = async <T,>(work: Promise<T>, fallback: T): Promise<T> => {
38 try {
39 return await work
40 } catch {
41 return fallback
42 }
43}
44
45const readJson = async <T,>($: EngineInterface, path: string): Promise<T | undefined> => {
46 const text = await safe<string>($.fs.read(path, { as: 'text' }) as Promise<string>, '')
47 if (!text) return undefined
48 try {
49 return JSON.parse(text) as T
50 } catch {
51 return undefined
52 }
53}
54
55const root = ($: EngineInterface) => $.plugin.root.replace(/\\/g, '/')
56
57const loadConfig = async ($: EngineInterface) => {
58 const cfg = await readJson<Config>($, `${root($)}/config.json`)
59 const home = (await safe($.env.get('USERPROFILE'), undefined)) ?? (await safe($.env.get('HOME'), undefined)) ?? ''
60 const out = expandHome(cfg?.paths.out_dir ?? '~/.claude/effort-board/control', home)
61 return { cfg, out, stale: cfg?.scan.stale_minutes ?? 60 }
62}
63
64// One scan at a time per module copy; the atoms carry what the panes show.
65let running = false
66const scan = async ($: EngineInterface) => {
67 if (running) return
68 running = true
69 await update($, scanning, () => true)
70 let problem = ''
71 try {
72 const r = await $.process.run(
73 ['python', '-I', `${root($)}/scanner/control_scan.py`, 'all', '--config', `${root($)}/config.json`],
74 { timeoutMs: 600000 },
75 )
76 if (r.exitCode !== 0) problem = cut((r.stderr.trim().split('\n').pop() ?? '') || `exit ${r.exitCode}`, 200)
77 } catch (err) {
78 problem = cut(String(err), 200)
79 }
80 running = false
81 await update($, error, () => problem)
82 await update($, scanning, () => false)
83 await update($, tick, n => n + 1)
84 if (problem) $.ui.toast(`control: scan failed: ${problem}`)
85}
86const scanSoon = ($: EngineInterface) => {
87 $.clock.after(0, () => void scan($))
88}
89
90// The line every pane starts with: scan age, last error, Rescan.
91const header = async ($: EngineInterface, e: ResolveInput, generated?: string) => {
92 const { Box, Text, Button } = $.ui.resolve(e)
93 const busy = await read($, scanning)
94 const problem = await read($, error)
95 return (
96 <Box flexDirection="row" gap={1}>
97 <Text dimColor>
98 {busy ? 'scanning…' : `scanned ${ageText(generated, Date.now())}`}
99 {problem ? ` · last scan failed: ${problem}` : ''}
100 </Text>
101 <Button key="rescan" hotkey="r" plain dimColor onPress={() => scanSoon($)}>
102 Rescan
103 </Button>
104 </Box>
105 )
106}
107
108export const register: Register = on => {
109 on('session.start', async ($, e, next) => {
110 await $.command.register({
111 name: 'control',
112 description: 'Claude Control panes: SETUP, USAGE, PRACTICES, HISTORY (args: setup | usage | practices | history | scan)',
113 argumentHint: '[setup|usage|practices|history] [scan]',
114 })
115 return next(e)
116 })
117
118 on('command.run', { command: 'control' }, async ($, e) => {
119 const { panes, scan: force } = parseArgs(e.args ?? '')
120 for (const key of panes) await $.ui.open({ id: PANES[key].id, title: PANES[key].title })
121 const { out, stale } = await loadConfig($)
122 const usage = await readJson<UsageData>($, `${out}/usage.json`)
123 const setup = await readJson<SetupData>($, `${out}/setup.json`)
124 const old = isStale(usage?.generated, Date.now(), stale) || isStale(setup?.generated, Date.now(), stale)
125 if (force || old) scanSoon($)
126 return {
127 text:
128 `control: opened ${panes.map(k => PANES[k].title).join(', ')}` +
129 (force || old ? ` · scanning (data ${ageText(usage?.generated, Date.now())})` : ` · data ${ageText(usage?.generated, Date.now())}`),
130 }
131 })
132
133 // ------------------------------------------------------------------ SETUP
134 on('ui.render', { component: 'Pane', requestId: PANES.setup.id }, async ($, e) => {
135 const { Box, Text, Button } = $.ui.resolve(e)
136 await read($, tick)
137 const open = await read($, section)
138 const { out } = await loadConfig($)
139 const data = await readJson<SetupData>($, `${out}/setup.json`)
140 const width = Math.max(40, e.props.bodyColumns)
141 if (!data) {
142 return (
143 <Box flexDirection="column">
144 {await header($, e)}
145 <Text>No setup.json yet in {out}: press Rescan.</Text>
146 </Box>
147 )
148 }
149 const row = (it: SetupItem, showSection = false) => (
150 <Box flexDirection="column" marginBottom={0}>
151 <Text>
152 <Text bold color={it.hidden ? 'warning' : undefined}>
153 {it.hidden ? '⚑ ' : ''}
154 {it.name}
155 </Text>
156 <Text dimColor>
157 {showSection && it.section ? ` · ${it.section}` : ''}
158 {it.size != null ? ` · ${kb(it.size)}` : ''}
159 {it.changed ? ` · ${it.changed}` : ''}
160 </Text>
161 </Text>
162 <Text wrap="wrap"> {cut(it.what, width * 2)}</Text>
163 {it.flags.map(f => (
164 <Text color={f.kind === 'stale_clause' || f.kind === 'blocker' ? 'error' : 'warning'} wrap="wrap">
165 {' '}! {cut(f.text, width * 2)}
166 </Text>
167 ))}
168 <Text dimColor wrap="truncate-start"> {it.path}</Text>
169 </Box>
170 )
171 const flagged = (s: SetupData['sections'][number]) => s.items.filter(it => it.flags.length).length
172 return (
173 <Box flexDirection="column">
174 {await header($, e, data.generated)}
175 <Text bold>{data.rules_version}</Text>
176 {(data.blockers ?? []).length === 0 ? (
177 <Text bold color="success">● Blockers: none (V5 §6: no hook, mod or script blocks or rewrites a tool call)</Text>
178 ) : (
179 <Box flexDirection="column">
180 <Text bold color="error">
181 ○ Blockers ({data.active_blockers ?? 0} active, {(data.blockers ?? []).length} found): V5 §6 expects none
182 </Text>
183 {(data.blockers ?? []).map(it => row(it))}
184 </Box>
185 )}
186 <Text> </Text>
187 <Text bold color="warning">
188 Hidden rules ({data.hidden.length}): they change behaviour without being in CLAUDE.md
189 </Text>
190 {data.hidden.map(it => row(it, true))}
191 <Text> </Text>
192 <Text bold>Sections (press one to list it)</Text>
193 {data.sections.map(s => (
194 <Box flexDirection="column">
195 <Button
196 key={`sec-${s.id}`}
197 plain
198 onPress={() => void update($, section, cur => (cur === s.id ? '' : s.id))}
199 >
200 {`${open === s.id ? '▾' : '▸'} ${s.title} (${s.items.length}${flagged(s) ? `, ${flagged(s)} flagged` : ''})`}
201 </Button>
202 {open === s.id && s.items.map(it => row(it))}
203 </Box>
204 ))}
205 </Box>
206 )
207 })
208
209 // ------------------------------------------------------------------ USAGE
210 on('ui.render', { component: 'Pane', requestId: PANES.usage.id }, async ($, e) => {
211 const { Box, Text, Button } = $.ui.resolve(e)
212 await read($, tick)
213 const wid = await read($, windowId)
214 const { out } = await loadConfig($)
215 const data = await readJson<UsageData>($, `${out}/usage.json`)
216 const w: UsageWindow | undefined = data?.windows.find(x => x.id === wid) ?? data?.windows[0]
217 if (!data || !w) {
218 return (
219 <Box flexDirection="column">
220 {await header($, e)}
221 <Text>No usage.json yet in {out}: press Rescan.</Text>
222 </Box>
223 )
224 }
225 const s = w.summary
226 const metric = (m: Metric) => (
227 <Box flexDirection="column">
228 <Text>
229 <Text color={stateColor(m.state)}>{stateMark(m.state)} </Text>
230 <Text bold>{m.label}: </Text>
231 <Text color={stateColor(m.state)}>{metricValue(m.value, m.unit)}</Text>
232 <Text dimColor> target {m.target}</Text>
233 </Text>
234 <Text dimColor wrap="wrap">
235 {' '}
236 {m.detail} · {m.rule}
237 </Text>
238 </Box>
239 )
240 return (
241 <Box flexDirection="column">
242 {await header($, e, data.generated)}
243 <Box flexDirection="row" gap={1}>
244 {data.windows.map((x, i) => (
245 <Button
246 key={`win-${x.id}`}
247 hotkey={String(i + 1)}
248 variant={x.id === w.id ? 'primary' : undefined}
249 onPress={() => void update($, windowId, () => x.id)}
250 >
251 {x.label}
252 </Button>
253 ))}
254 </Box>
255 <Text dimColor>
256 {w.from}h → {w.to}h UTC · {w.days} days · {big(s.turns)} turns · {usd(s.usd)} estimated · main {big(s.main_turns)} / subagents {big(s.sub_turns)}
257 </Text>
258 <Text> </Text>
259 <Text bold>Per model</Text>
260 {s.models.map(r => (
261 <Text>
262 {r.model.padEnd(7)} {big(r.turns).padStart(7)} turns ({pct(r.turns, s.turns)}) · main {big(r.main)} / sub {big(r.sub)} · out {big(r.out)} · cache+in {big(r.cache_in)} · {usd(r.usd)} (main {usd(r.usd_main)}, sub {usd(r.usd_sub)})
263 </Text>
264 ))}
265 <Text bold>Per effort (model effort: turns · $)</Text>
266 <Text wrap="wrap">{s.efforts.slice(0, 10).map(r => `${r.key} ${big(r.turns)} · ${usd(r.usd)}`).join(' ')}</Text>
267 <Text bold>Delegation</Text>
268 <Text wrap="wrap">
269 Agent {s.agent.total} calls: {s.agent.with_model} with model ({Object.entries(s.agent.models).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}), {s.agent.no_model} without · {s.agent.background} background · {s.agent.worktree} worktree · types {s.agent.types.slice(0, 6).map(([k, v]) => `${k} ${v}`).join(', ')}
270 </Text>
271 <Text wrap="wrap">
272 Workflow {s.workflow.runs} runs, {s.workflow.agent_calls} agent() calls, {s.workflow.with_model} with a model ({Object.entries(s.workflow.models).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'})
273 </Text>
274 <Text wrap="wrap">
275 Read-only calls: {Object.entries(s.readonly).map(([k, v]) => `${k} ${big(v)}`).join(', ')} · plan mode {s.tools.plan_mode} · /goal {s.tools.goal} · /loop {s.tools.loop} · Monitor {s.tools.monitor} · CronCreate {s.tools.cron} · auto-compactions {s.tools.compact_auto} vs hand-offs {s.tools.handoff} · turns ≥300K {big(s.context.main_300k ?? 0)}
276 </Text>
277 <Text> </Text>
278 <Text bold>Against the rules (V4.8 routing, V4.9)</Text>
279 {w.metrics.map(metric)}
280 <Text> </Text>
281 <Text bold>Top repos by $</Text>
282 <Text wrap="wrap">{s.projects.slice(0, 8).map(p => `${p.project.replace(/^[A-Za-z]--/, '')} ${usd(p.usd)}`).join(' · ')}</Text>
283 <Text dimColor>Prices are estimates from config.json (cache read 0.1×, write 1.25× input).</Text>
284 </Box>
285 )
286 })
287
288 // ------------------------------------------------------------------ PRACTICES
289 on('ui.render', { component: 'Pane', requestId: PANES.practices.id }, async ($, e) => {
290 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
291 await read($, tick)
292 const ref = await read($, showReference)
293 const { out } = await loadConfig($)
294 const data = await readJson<PracticesData>($, `${out}/practices.json`)
295 if (!data) {
296 return (
297 <Box flexDirection="column">
298 {await header($, e)}
299 <Text>No practices.json yet in {out}: press Rescan.</Text>
300 </Box>
301 )
302 }
303 const list = (title: string, items: string[], color: string) => (
304 <Box flexDirection="column">
305 <Text bold color={color}>
306 {title}
307 </Text>
308 {items.length ? items.map((x, i) => <Text wrap="wrap">{`${i + 1}. ${x}`}</Text>) : <Text dimColor>none</Text>}
309 </Box>
310 )
311 return (
312 <Box flexDirection="column">
313 {await header($, e, data.generated)}
314 <Text dimColor>
315 {data.rules_version} · measured on {data.window ?? 'no usage data'}
316 </Text>
317 {data.practices.map(p => (
318 <Box flexDirection="column">
319 <Text>
320 <Text color={stateColor(p.state)}>{stateMark(p.state)} </Text>
321 <Text bold>{p.title}</Text>
322 <Text dimColor> · {p.state}</Text>
323 </Text>
324 <Text dimColor wrap="wrap">
325 {' '}rule: {p.rule_line}
326 </Text>
327 {p.evidence.map(x => (
328 <Text wrap="wrap">
329 {' '}→ {x}
330 </Text>
331 ))}
332 </Box>
333 ))}
334 <Box flexDirection="row" gap={1}>
335 <Button key="to-usage" plain dimColor onPress={() => void $.ui.open({ id: PANES.usage.id, title: PANES.usage.title })}>
336 Open USAGE for the numbers
337 </Button>
338 </Box>
339 <Text> </Text>
340 {list('5 best habits (measured)', data.best, 'success')}
341 {list('5 worst habits (measured)', data.worst, 'error')}
342 {list('Audit 9 Oct: what we do right', data.audit_right, 'success')}
343 {list('Audit 9 Oct: what we do wrong', data.audit_wrong, 'error')}
344 <Text> </Text>
345 <Button key="reference" plain onPress={() => void update($, showReference, v => !v)}>
346 {`${ref ? '▾' : '▸'} Reference: docs/PRACTICES.md (good and bad practices with evidence, Anthropic guidance)`}
347 </Button>
348 {ref && (data.reference_md ? <Markdown text={data.reference_md} /> : <Text dimColor>docs/PRACTICES.md not found</Text>)}
349 </Box>
350 )
351 })
352
353 // ------------------------------------------------------------------ HISTORY
354 on('ui.render', { component: 'Pane', requestId: PANES.history.id }, async ($, e) => {
355 const { Box, Text, Markdown } = $.ui.resolve(e)
356 await read($, tick)
357 const { out } = await loadConfig($)
358 const data = await readJson<PracticesData>($, `${out}/practices.json`)
359 return (
360 <Box flexDirection="column">
361 {await header($, e, data?.generated)}
362 {data?.history_md ? (
363 <Markdown text={data.history_md} />
364 ) : (
365 <Text>No history yet: press Rescan (reads docs/HISTORY.md).</Text>
366 )}
367 </Box>
368 )
369 })
370}
371hooks/view.ts 65 lines1// Pure helpers of the control mod: formatting and freshness. No `$`, so tests call them directly.
2
3export const PANES = {
4 setup: { id: 'control-setup', title: 'SETUP' },
5 usage: { id: 'control-usage', title: 'USAGE' },
6 practices: { id: 'control-practices', title: 'PRACTICES' },
7 history: { id: 'control-history', title: 'HISTORY' },
8} as const
9export type PaneKey = keyof typeof PANES
10
11/** `/control`, `/control usage`, `/control scan`: which panes to open (all four by default) and whether to rescan first. */
12export const parseArgs = (args: string): { panes: PaneKey[]; scan: boolean } => {
13 const words = args.toLowerCase().split(/\s+/).filter(Boolean)
14 const panes = words.filter((w): w is PaneKey => w in PANES)
15 return { panes: panes.length ? panes : (Object.keys(PANES) as PaneKey[]), scan: words.includes('scan') }
16}
17
18/** Minutes since an ISO time ('2026-10-09T15:50:11Z'); Infinity when absent or unreadable. */
19export const ageMinutes = (iso: string | undefined, now: number): number => {
20 const t = iso ? Date.parse(iso) : NaN
21 return Number.isNaN(t) ? Infinity : Math.max(0, (now - t) / 60000)
22}
23
24export const isStale = (iso: string | undefined, now: number, staleMinutes: number) =>
25 ageMinutes(iso, now) > staleMinutes
26
27export const ageText = (iso: string | undefined, now: number): string => {
28 const m = ageMinutes(iso, now)
29 if (!Number.isFinite(m)) return 'never'
30 if (m < 1) return 'just now'
31 if (m < 90) return `${Math.round(m)} min ago`
32 if (m < 48 * 60) return `${Math.round(m / 60)} h ago`
33 return `${Math.round(m / 1440)} days ago`
34}
35
36/** 27_495_461 -> '27.5 M', 4_263 -> '4,263'. */
37export const big = (n: number): string => {
38 if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(2)} B`
39 if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(1)} M`
40 return Math.round(n).toLocaleString('en-US')
41}
42
43export const usd = (n: number): string => `$${n >= 100 ? Math.round(n).toLocaleString('en-US') : n.toFixed(2)}`
44
45export const pct = (part: number, whole: number): string => (whole ? `${((100 * part) / whole).toFixed(1)} %` : 'n/a')
46
47export const kb = (n: number | null): string =>
48 n == null ? '' : n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`
49
50/** The dot and theme color of a red/amber/green state. */
51export const stateColor = (state: string): string =>
52 state === 'green' ? 'success' : state === 'amber' ? 'warning' : state === 'red' ? 'error' : 'subtle'
53
54export const stateMark = (state: string): string =>
55 state === 'green' ? '●' : state === 'amber' ? '◐' : state === 'red' ? '○' : '·'
56
57export const metricValue = (value: number | null, unit: string): string =>
58 value == null ? 'n/a' : unit === '%' ? `${value} %` : unit === '/day' ? `${value}/day` : `${value}`
59
60/** '~/x' -> '<home>/x' with the home folder from the environment. */
61export const expandHome = (p: string, home: string): string =>
62 p.startsWith('~') ? `${home.replace(/\\/g, '/')}${p.slice(1)}` : p.replace(/\\/g, '/')
63
64export const cut = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
65types/index.d.ts 123 lines1// Shapes of the JSON the scanner writes (scanner/control_scan.py) and the `$.state` values of the control mod.
2
3export type Flag = { kind: string; text: string }
4export type SetupItem = {
5 name: string
6 path: string
7 exists: boolean
8 size: number | null
9 changed: string | null
10 what: string
11 flags: Flag[]
12 hidden: boolean
13 section?: string
14}
15export type SetupSection = { id: string; title: string; items: SetupItem[] }
16export type SetupData = {
17 generated: string
18 rules_version: string
19 sections: SetupSection[]
20 hidden: SetupItem[]
21 /** hooks, mod deny paths, blocking scripts, permission deny rules (V5 expects none active) */
22 blockers?: SetupItem[]
23 active_blockers?: number
24 counts: Record<string, number>
25}
26
27export type MetricState = 'green' | 'amber' | 'red' | 'info' | 'none'
28export type Metric = {
29 id: string
30 label: string
31 unit: string
32 value: number | null
33 detail: string
34 state: MetricState
35 target: string
36 rule: string
37}
38export type ModelRow = {
39 model: string
40 turns: number
41 main: number
42 sub: number
43 out: number
44 cache_in: number
45 usd: number
46 usd_main: number
47 usd_sub: number
48}
49export type Summary = {
50 turns: number
51 usd: number
52 models: ModelRow[]
53 efforts: { key: string; turns: number; usd: number }[]
54 projects: { project: string; turns: number; usd: number }[]
55 main_turns: number
56 sub_turns: number
57 agent: {
58 total: number
59 with_model: number
60 no_model: number
61 background: number
62 worktree: number
63 types: [string, number][]
64 models: Record<string, number>
65 }
66 workflow: { runs: number; agent_calls: number; with_model: number; models: Record<string, number> }
67 tools: Record<string, number>
68 context: Record<string, number>
69 readonly: Record<string, number>
70}
71export type UsageWindow = {
72 id: string
73 label: string
74 from: string
75 to: string
76 days: number
77 summary: Summary
78 metrics: Metric[]
79}
80export type UsageData = { generated: string; files: number; rescanned: number; windows: UsageWindow[] }
81
82export type Practice = {
83 id: string
84 title: string
85 rule_line: string
86 in_rules: boolean
87 state: string
88 metrics: string[]
89 evidence: string[]
90}
91export type PracticesData = {
92 generated: string
93 window: string | null
94 rules_version: string
95 practices: Practice[]
96 best: string[]
97 worst: string[]
98 audit_right: string[]
99 audit_wrong: string[]
100 /** docs/PRACTICES.md and docs/HISTORY.md as read at scan time */
101 reference_md: string
102 history_md: string
103}
104
105declare module 'claude-code' {
106 interface PluginState {
107 control: {
108 /** bumped after a scan so every pane re-reads its file */
109 tick: number
110 /** a scan is running (started by /control or a Rescan button) */
111 scanning: boolean
112 /** last scan's error line, '' when it went fine */
113 error: string
114 /** USAGE window id: 1d, 7d, 30d or ref */
115 window: string
116 /** SETUP section shown in full ('' = hidden rules and section heads only) */
117 section: string
118 /** PRACTICES pane shows docs/PRACTICES.md in full */
119 showReference: boolean
120 }
121 }
122}
123