SLOPSHOPPER

mogger-status

Shows that the mogger guard hooks are running: status line, a bar above the prompt, and a toast when mogger blocks or warns.

newbandtoaststatustimer
★ 1v0.1.0MITupdated 2026-10-02yonitesser/claude-mogger/mods/mogger-status
A shopper browsing a rack in a slop shop
README

mogger-status (optional Claude Code mod)

Shows that mogger is running and helping. It adds three things to the Claude Code screen:

  • Status line: mogger: N guards fired, M blocked (counted since the session started).
  • Bar above the prompt: the last thing mogger did, for example Last: blocked a secret in config.js (12s ago). It turns dim after 60 seconds of quiet. It has a Hide button. It is not drawn when there is no log yet.
  • Toast: a short pop-up each time mogger blocks something new.

How it works

Each mogger guard hook calls mogger_event (in hooks/scripts/lib.sh). That adds one line to .claude/state/mogger-events.log in your project:

<epoch seconds> TAB <kind> TAB <short message>

kind is block, warn, ok (an action ran, such as auto-format or a checkpoint) or info. Messages use file basenames only: no command text, no secrets, no full paths. The log is cut to about the last 200 lines. If the log cannot be written, the hook goes on as before: logging never changes a hook's exit code or output. The log is kept out of checkpoints.

The mod reads that file with $.fs every 2 seconds. It does not read hook results directly.

Enable

Mods need a Claude Code version that supports them (see the mods blog post). This mod is opt-in: installing mogger does not turn it on.

Try it for one session:

claude --plugin-dir /path/to/claude-mogger/mods/mogger-status

Check it first:

claude plugin validate mods/mogger-status
claude plugin test mods/mogger-status

Safety

Mods are not sandboxed. A mod runs code inside your Claude Code session with the same access as Claude Code itself. Read hooks/register.tsx and hooks/log.ts before you enable it. This one only reads .claude/state/mogger-events.log, and shows text. It does not write files, run commands or use the network.

Source 3 files
hooks/register.tsx 82 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MoggerSummary } from '../types'
5import { LOG_PATH, ageText, newBlocks, newestTs, parseLog, statusText, summarize } from './log'
6
7const summaryAtom = atom({ plugin: 'mogger-status', key: 'summary' } as const, null as MoggerSummary | null)
8const isHidden = atom({ plugin: 'mogger-status', key: 'isHidden' } as const, false)
9const sinceAtom = atom({ plugin: 'mogger-status', key: 'since' } as const, 0)
10
11const QUIET_MS = 60_000
12const POLL_MS = 2000
13
14// newest event time already seen (seconds); -1 until the first read, so
15// old blocks from earlier sessions never toast.
16let seenTs = -1
17
18async function refresh($: EngineInterface): Promise<void> {
19  let text = ''
20  try {
21    text = await $.fs.read(LOG_PATH)
22  } catch {
23    // no log yet: show nothing
24    $.ui.status(undefined)
25    await update($, summaryAtom, () => null)
26    return
27  }
28  const events = parseLog(text)
29  let since = await read($, sinceAtom)
30  if (since === 0) {
31    since = await $.clock.now()
32    await update($, sinceAtom, () => since)
33  }
34  const s = summarize(events, since)
35  if (seenTs >= 0) {
36    for (const b of newBlocks(events, seenTs)) {
37      $.ui.toast(`mogger ${b.msg}`)
38    }
39  }
40  seenTs = newestTs(events)
41  await update($, summaryAtom, () => s)
42  $.ui.status(statusText(s))
43}
44
45export const register: Register = on => {
46  on('session.start', async ($, e, next) => {
47    await refresh($)
48    $.clock.every(POLL_MS, () => refresh($))
49    return next(e)
50  })
51
52  on('turn.complete', async ($, e, next) => {
53    await refresh($)
54    return next(e)
55  })
56
57  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
58    const s = await read($, summaryAtom)
59    if (e.props.hasSurvey || s === null || (await read($, isHidden))) {
60      return next(e)
61    }
62    const { Box, Button, Text } = $.ui.resolve(e)
63    const now = await $.clock.now()
64    const last = s.last
65    const isQuiet = last === null || now - last.ts * 1000 > QUIET_MS
66    const color = last === null ? undefined : last.kind === 'block' ? 'red' : last.kind === 'warn' ? 'yellow' : 'green'
67    const line =
68      last === null
69        ? `${statusText(s)}. Nothing to report yet.`
70        : `${statusText(s)}. Last: ${last.msg} (${ageText(now, last.ts)})`
71
72    return (
73      <Box>
74        <Text dimColor={isQuiet} color={isQuiet ? undefined : color}>
75          {line}{' '}
76        </Text>
77        <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
78      </Box>
79    )
80  })
81}
82
hooks/log.ts 55 lines
1// Pure helpers for the mogger event log. One line per event:
2//   <epoch seconds> TAB <kind> TAB <short message>
3// Written by mogger_event in hooks/scripts/lib.sh. No secrets in the log.
4import type { MoggerEvent, MoggerSummary } from '../types'
5
6export const LOG_PATH = '.claude/state/mogger-events.log'
7
8// kinds that count as "a guard fired". "info" lines (session start) do not.
9const COUNTED = ['block', 'warn', 'ok']
10
11export function parseLog(text: string): MoggerEvent[] {
12  const events: MoggerEvent[] = []
13  for (const line of text.split('\n')) {
14    const parts = line.split('\t')
15    if (parts.length < 3) continue
16    const ts = Number(parts[0])
17    if (!Number.isFinite(ts) || ts <= 0) continue
18    const kind = parts[1] ?? ''
19    if (kind === '') continue
20    events.push({ ts, kind, msg: parts.slice(2).join(' ').slice(0, 120) })
21  }
22  return events
23}
24
25// sinceMs: only count events at or after this time (0 = all).
26export function summarize(events: MoggerEvent[], sinceMs: number): MoggerSummary {
27  const mine = events.filter(e => e.ts * 1000 >= sinceMs)
28  const counted = mine.filter(e => COUNTED.includes(e.kind))
29  return {
30    fired: counted.length,
31    blocked: counted.filter(e => e.kind === 'block').length,
32    last: counted.length > 0 ? (counted[counted.length - 1] as MoggerEvent) : null,
33  }
34}
35
36export function statusText(s: MoggerSummary): string {
37  return `mogger: ${s.fired} guards fired, ${s.blocked} blocked`
38}
39
40export function newestTs(events: MoggerEvent[]): number {
41  return events.reduce((m, e) => Math.max(m, e.ts), 0)
42}
43
44// Events to toast: blocks newer than lastTs.
45export function newBlocks(events: MoggerEvent[], lastTs: number): MoggerEvent[] {
46  return events.filter(e => e.kind === 'block' && e.ts > lastTs)
47}
48
49export function ageText(nowMs: number, ts: number): string {
50  const s = Math.max(0, Math.round(nowMs / 1000 - ts))
51  if (s < 60) return `${s}s ago`
52  if (s < 3600) return `${Math.floor(s / 60)}m ago`
53  return `${Math.floor(s / 3600)}h ago`
54}
55
types/index.d.ts 17 lines
1export type MoggerEvent = { ts: number; kind: string; msg: string }
2export type MoggerSummary = {
3  fired: number
4  blocked: number
5  last: MoggerEvent | null
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'mogger-status': {
11      summary: MoggerSummary | null
12      isHidden: boolean
13      since: number
14    }
15  }
16}
17