SLOPSHOPPER

session-band

A status line under the prompt counting belt denials this session, shown from the first deny on.

newstatus
★ 1v0.2.0BSD-3-Clauseupdated 2026-10-09mad01/thismoon/mods/session-band
A shopper browsing a rack in a slop shop
README

session-band

A Claude Code mod that counts belt denials in this session and pins the count in the status row under the prompt, from the first deny on:

session-band: belt denies 2

The engine prefixes a plugin's status line with its name; the text the mod composes starts at belt. Before the first deny the line stays clear, so a quiet session shows nothing. Nothing is drawn above the prompt: the mod registers no ui.render hook, so the engine's band area stays hidden.

Where the count comes from

classic.PreToolUse: the decision next(e) hands back is belt's own, and a deny whose reason carries belt[<guard>]: anywhere counts as one (the engine may wrap the reason in its own hook-error prefix). The count is per session, since the mod only sees this session's calls. It lives in $.state, so it survives a hot reload and a compaction; /clear puts it back to zero and the line goes with it on the next tool call.

pi face

pi/index.ts keeps the same count in the pi coding agent. pi has no hook chain to read a decision from, so the dotfiles permission gate announces each belt denial on the extension bus as belt:deny; the face counts those, keeps the count in the session (pi.appendEntry, restored from the active branch), and pins belt denies N with ctx.ui.setStatus. Silent without a UI.

Dev loop

claude --plugin-dir mods/session-band        # from the repo root; a save hot-reloads
claude --debug --plugin-dir mods/session-band  # every pin lands in the debug log as `$.ui.status (session-band): <text>`
claude plugin validate mods/session-band     # what the engine would refuse
node --test mods/session-band/test/*.test.mjs  # lib and the pi face
npx -p typescript tsc -p mods/session-band   # after one load wrote .claude-plugin/types/

The engine writes .claude-plugin/types/ beside the mod on load; those files are the authority on event shapes for the build you run. Every classic.PreToolUse decision is described in the debug log (its keys and the head of its JSON), so a deny that failed to count can be read off the log.

What it never does

  • never blocks: every hook carries a .catch that logs to the debug log and passes the event on, and the PreToolUse observer returns belt's decision untouched
  • never edits the transcript or the model's answer
  • never probes a service: the one signal comes from an event the session already raises
  • never required by any skill, hook, or service; belt stays the guard
Source 3 files
hooks/register.tsx 60 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { composeStatusText, decisionSummary, isBeltDeny } from '../lib/band'
5
6const beltDenies = atom({ plugin: 'session-band', key: 'beltDenies' } as const, 0)
7
8type Logger = { ui: { log: (text: string, options: { to: 'debug' }) => void } }
9type Failed<E, R> = ((e: E) => R) & { event: string; error: { kind: string; message?: string } }
10
11// The one .catch every hook below carries: say why in the debug log, then
12// let the chain beneath answer as if the hook were absent. The line never
13// blocks a turn or a tool call.
14const skipOnFailure = <E, R>($: Logger, e: E, next: Failed<E, R>): R => {
15  const why = next.error.message === undefined ? next.error.kind : `${next.error.kind}: ${next.error.message}`
16  $.ui.log(`session-band: ${next.event} skipped (${why})`, { to: 'debug' })
17  return next(e)
18}
19
20// The text this module instance last pinned; null before its first pin. A
21// hot reload starts over and pins again, and the engine keeps one status
22// line per plugin either way.
23let pinned: string | undefined | null = null
24
25// Recomposes the line and pins it when it changed; undefined clears it.
26async function refreshStatus($: EngineInterface): Promise<void> {
27  const text = composeStatusText(await read($, beltDenies))
28  if (pinned !== null && text === pinned) return
29  pinned = text
30  $.ui.status(text)
31}
32
33export const register: Register = on => {
34  // The count lives in $.state and outlives a reload; the pin does not, so
35  // a count carried across a hot reload is put back on screen here.
36  on('session.start', async ($, e, next) => {
37    const started = await next(e)
38    $.ui.log('session-band: loaded', { to: 'debug' })
39    await refreshStatus($)
40    return started
41  }).catch(skipOnFailure)
42
43  // belt answers PreToolUse as a settings hook beneath every mod; its deny
44  // comes back from next(e) with a `belt[<guard>]: ` reason, possibly inside
45  // the engine's own wrapping. Counted, never changed: the decision returns
46  // exactly as belt made it. Every decision is described in the debug log so
47  // its real shape can be read off `claude --debug`. The line is refreshed
48  // on every call, not only on a deny, so a /clear that zeroed the count
49  // takes the stale line down with it.
50  on('classic.PreToolUse', async ($, e, next) => {
51    const decision = await next(e)
52    if (decision !== undefined) {
53      $.ui.log(`session-band: PreToolUse ${e.tool} decision ${decisionSummary(decision)}`, { to: 'debug' })
54    }
55    if (isBeltDeny(decision)) await update($, beltDenies, n => n + 1)
56    await refreshStatus($)
57    return decision
58  }).catch(skipOnFailure)
59}
60
lib/band.ts 52 lines
1// Pure helpers behind the session-band mod: no `$`, no I/O, so `node --test`
2// covers them without the engine. register.tsx is the only caller.
3
4// Every belt denial reason carries `belt[<guard>]: `; other settings hooks
5// deny with their own words. The engine may wrap the reason before a mod
6// sees it (`PreToolUse:Bash hook error: belt[git-push-main]: ...`), so the
7// marker is searched for anywhere in the text, never anchored.
8const BELT_REASON = /belt\[[^\]]+\]:/
9
10/** How many characters of a decision's JSON the debug line keeps. */
11const DECISION_JSON_CAP = 120
12
13/**
14 * Whether a `classic.PreToolUse` decision is a denial belt wrote. A string
15 * `deny` is read as the typed shape; an allow or an ask is never a denial;
16 * any other object is searched as JSON, so a shape the types do not name
17 * (a nested `permissionDecisionReason`, a `block`) still counts.
18 */
19export function isBeltDeny(decision: unknown): boolean {
20  if (decision === null || typeof decision !== 'object') return false
21  const { deny, allow, ask } = decision as { deny?: unknown; allow?: unknown; ask?: unknown }
22  if (typeof deny === 'string') return BELT_REASON.test(deny)
23  if (allow === true || typeof ask === 'string') return false
24  return BELT_REASON.test(stringify(decision))
25}
26
27function stringify(value: unknown): string {
28  try {
29    return JSON.stringify(value) ?? ''
30  } catch {
31    return ''
32  }
33}
34
35/** One debug line's worth of a decision: its keys and the head of its JSON. */
36export function decisionSummary(decision: unknown): string {
37  if (decision === null || typeof decision !== 'object') return `${typeof decision} ${String(decision)}`
38  const keys = Object.keys(decision).join(',')
39  const json = stringify(decision)
40  const head = json.length > DECISION_JSON_CAP ? `${json.slice(0, DECISION_JSON_CAP)}...` : json
41  return `keys [${keys}] ${head}`
42}
43
44/**
45 * The one line pinned under the prompt, or undefined (clear it) before the
46 * first deny. The engine prefixes a status line with the mod's name, so the
47 * text never repeats it.
48 */
49export function composeStatusText(beltDenies: number): string | undefined {
50  return beltDenies > 0 ? `belt denies ${beltDenies}` : undefined
51}
52
types/index.d.ts 11 lines
1/** How many belt denials this session has seen; zero keeps the line clear. */
2export type SessionBandDenies = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'session-band': {
7      beltDenies: SessionBandDenies
8    }
9  }
10}
11