SLOPSHOPPER

control

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…

newpanecommandtoastprocesstimer
v0.1.0MITupdated 2026-10-16julian-passebecq/claude-mod/mods/control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · control
│ ┃ SETUP ✕ › fix the failing auth test and add an audit log call │ ┃ scanned never r: Rescan │ ┃ No setup.json yet in ⏺ Read(src/auth.ts) │ ┃ /Users/dev/.claude/effort-board/control: ⎿ Read 6 lines │ ┃ press Rescan. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /control │ ⎿ control: control: opened SETUP, USAGE, PRACTICES, HISTORY · scan │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SETUP
scanned never r: Rescan No setup.json yet in /Users/dev/.claude/effort-board/control: press Rescan.
Pane · USAGE
scanned never r: Rescan No usage.json yet in /Users/dev/.claude/effort-board/control: press Rescan.
Pane · PRACTICES
scanned never r: Rescan No practices.json yet in /Users/dev/.claude/effort-board/control: press Rescan.
Pane · HISTORY
scanned never r: Rescan No history yet: press Rescan (reads docs/HISTORY.md).
README

claude-mod

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

The mods

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

Catalog

What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.

ModVerdictWhy
factorykeepThe 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.
controlkeepThe "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.
coordinatormerge into factoryLinked 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 bandSmall and correct: context size against the hand-off threshold, read-only. Not written yet.
cc-control guardsdropThey refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out.
cinew (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.
scenariosnew (Oct 2026)Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts.
guidenew (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, nightdropWrong numbers for new model ids, superseded by factory, or built on timer wake-ups.
V4 pages and watcherdropDead since early Oct, replaced by factory and the app's own agent view.

Install

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.

ci

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: --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.
  • Status check: token presence only (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: 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.
  • Needs Python 3 on PATH; gh and glab are optional.

Rules for every mod here

  • Show and warn, never block: no deny, no rewriting of tool calls or prompts.
  • Read-only on your files; each mod writes only its own cache and output folder (ci also writes its routing file, on /ci route and /ci clear).
  • No network except gh / glab for PR state and the GitLab API for runner status (ci).
  • Configurable legends, labels, colors and thresholds.
  • Tests next to the code.

Tests

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.

License

MIT

Source 3 files
hooks/register.tsx 371 lines
1// 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}
371
hooks/view.ts 65 lines
1// 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)
65
types/index.d.ts 123 lines
1// 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