SLOPSHOPPER

mod-freshness

Fingerprints the bbm mods a session loaded at start and checks the bbm-mods mirror hourly; when a loaded mod has changed upstream, wakes the session once to…

newtoastprocesstimer
A shopper browsing a rack in a slop shop
README

bbm-mods

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.

Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.

Install (what the cloud environment setup script runs):

claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope user
Source 4 files
hooks/register.ts 88 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { CHECK_MS, newlyStale, notCheckedText, RUN_TIMEOUT_MS, wakeText } from './logic'
5import type { Fingerprints } from '../types'
6import type { Verdict } from './logic'
7import { runArgs } from './py'
8
9// Why (LC-1711 follow-up, 2026-10-22): a session kept open across mod merges keeps running the
10// mod code its container installed at start. The Catalog orchestrator ran a fleet-tracker without
11// auto-track and with the old phone-push wording, and it read as "Claude ignores the mods".
12// This mod fingerprints the loaded mods at session start and, hourly, asks ./mod-freshness.py
13// whether the bbm-mods mirror now ships different code for any of them.
14
15const loaded = atom({ plugin: 'mod-freshness', key: 'loaded' } as const, {} as Fingerprints)
16const warned = atom({ plugin: 'mod-freshness', key: 'warned' } as const, [] as string[])
17
18// Wakes the host refused (submitted from inside a tool call) wait for the turn's end.
19const pendingWakes: string[] = []
20let ticking = false
21let busy = false
22let toldNotChecked = false
23
24async function runPy($: any, payload: unknown): Promise<any> {
25  try {
26    const { argv, init } = runArgs($.plugin.root, 'mod-freshness.py', payload)
27    const r = await $.process.run(argv, { ...init, timeoutMs: RUN_TIMEOUT_MS })
28    return r?.exitCode === 0 ? JSON.parse(String(r.stdout ?? '')) : undefined
29  } catch {
30    return undefined
31  }
32}
33
34function toast($: any, text: string) {
35  try { $.ui.toast(text.split('\n')[0]!.slice(0, 200)) } catch { /* a toast never changes a verdict */ }
36}
37
38function flush($: any) {
39  if (pendingWakes.length === 0) return
40  const lines = pendingWakes.splice(0)
41  toast($, lines[0]!)
42  Promise.resolve()
43    .then(() => $.prompt.submit({ text: lines.join('\n') }))
44    .catch(() => { pendingWakes.unshift(...lines) })
45}
46
47async function tick($: any) {
48  if (busy) return
49  busy = true
50  try {
51    const snap = await read($, loaded)
52    const v: Verdict | undefined = await runPy($, { mode: 'check', loaded: snap })
53    const nc = notCheckedText(v)
54    // NOT CHECKED is never read as fresh, but an hourly network blip is not worth a wake: one toast.
55    if (nc && !toldNotChecked) { toldNotChecked = true; toast($, nc) }
56    const fresh = newlyStale(v, await read($, warned))
57    if (v && fresh.length > 0) {
58      await update($, warned, w => [...w, ...fresh])
59      pendingWakes.push(wakeText(fresh, v))
60      flush($)
61    }
62  } catch { /* a failed tick is retried by the next one */ } finally {
63    busy = false
64  }
65}
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    const started = await next(e)
70    // The fingerprint must be of what was LOADED, so it is taken now, before anything can change on disk.
71    const dirs = (await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? ''
72    const snap = await runPy($, { mode: 'snapshot', plugin_dirs: dirs })
73    if (snap?.loaded && typeof snap.loaded === 'object') await update($, loaded, () => snap.loaded)
74    if (!ticking) {
75      ticking = true
76      $.clock.every(CHECK_MS, () => { void tick($) })
77    }
78    return started
79  // Fail open: a bug here must never cost the session its start.
80  }).catch(($, e, next) => next(e))
81
82  on('turn.complete', async ($, e, next) => {
83    const res = await next(e)
84    flush($)
85    return res
86  })
87}
88
hooks/logic.ts 37 lines
1// Pure decisions for mod-freshness. The comparison itself lives in ./mod-freshness.py (its own
2// --selftest); this file only decides what to say about its answer.
3
4export const CHECK_MS = 60 * 60_000 // hourly: a merge-to-mirror takes minutes, a stale session lasts hours
5export const RUN_TIMEOUT_MS = 60_000 // the check fetches the mirror over the network
6
7export type Verdict = {
8  stale?: string[]
9  fresh?: string[]
10  not_checked?: Record<string, string>
11  mirror?: string
12}
13
14/** Stale mods not yet reported this session. Each mod is reported once; a newly stale one is reported again. */
15export function newlyStale(v: Verdict | undefined, warned: readonly string[]): string[] {
16  return (v?.stale ?? []).filter(m => !warned.includes(m))
17}
18
19/** The wake text for newly stale mods. */
20export function wakeText(stale: readonly string[], v: Verdict): string {
21  const names = stale.join(', ')
22  const head = v.mirror ? ` The mirror now ships: ${v.mirror}.` : ''
23  return `[mod:mod-freshness] This session is running out-of-date mod code: ${names}.${head} ` +
24    'Mods load once, when the container starts, so fixes merged since then are not active here. ' +
25    'Tell the operator in chat, opening with the NEEDS YOU banner: start a fresh session to pick them up ' +
26    '(carry open work over with the session-handoff skill). Until then, do the stale mod\'s job by hand ' +
27    'where it matters (for fleet-tracker: track every child session yourself, never a timed check-in).'
28}
29
30/** A NOT CHECKED line for the operator's toast, or undefined when everything was checked. */
31export function notCheckedText(v: Verdict | undefined): string | undefined {
32  const nc = v?.not_checked ?? {}
33  const why = nc['*'] ?? Object.values(nc)[0]
34  if (v && why === undefined) return undefined
35  return `[mod:mod-freshness] NOT CHECKED: ${why ?? 'the check returned nothing'}`
36}
37
hooks/py.ts 87 lines
1// Shared by every policy mod that wraps a Python check (LCH-528). A mod cannot import a
2// sibling, so each carries a byte-identical copy; claude-mods.yml fails if the copies drift.
3//
4// Why Python under a mod: the checks were built and mutation-tested as stdlib Python
5// (classic hook in, classic hook out). The mod is how they reach every repo's sessions;
6// the .py file stays the single place the rule's logic lives, with its own --selftest.
7//
8// PURE BY CONSTRAINT: the plugin validator (claude-code 2.1.292, the version CI pins) follows
9// `$` only into functions declared in the hook's own file. So nothing here takes `$`; each
10// register.ts keeps three small `$` helpers (runCheck, preToolUse, blockOf) over these.
11
12export type HookOut = {
13  hookSpecificOutput?: {
14    permissionDecision?: string
15    permissionDecisionReason?: string
16    additionalContext?: string
17  }
18  decision?: string
19  reason?: string
20}
21
22// tool.call envelope fields that are the engine's, not the tool's arguments.
23const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
24
25/** The classic hook payload's tool fields, rebuilt from a tool.call envelope. */
26export function toolPayload(e: Record<string, unknown>): { tool_name: string; tool_input: Record<string, unknown> } {
27  const tool_input: Record<string, unknown> = {}
28  for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) tool_input[k] = v
29  return { tool_name: String(e.tool ?? ''), tool_input }
30}
31
32/** argv and init for `$.process.run`: hooks/<script> with the payload on stdin. */
33export function runArgs(root: string, script: string, payload: unknown): { argv: string[]; init: { stdin: string; timeoutMs: number } } {
34  return { argv: ['python3', '-I', `${root}/hooks/${script}`], init: { stdin: JSON.stringify(payload), timeoutMs: 10_000 } }
35}
36
37/**
38 * The check's JSON output, or undefined on a non-zero exit, silence or junk. The checks fail
39 * open (a guard that fails shut blocks every call and gets removed), as they did as hooks.
40 */
41export function parseOut(r: { exitCode?: number; stdout?: unknown }): HookOut | undefined {
42  const out = String(r?.stdout ?? '').trim()
43  if (r?.exitCode !== 0 || !out) return undefined
44  try {
45    const v = JSON.parse(out)
46    return v && typeof v === 'object' ? (v as HookOut) : undefined
47  } catch {
48    return undefined
49  }
50}
51
52/**
53 * A classic.PreToolUse answer from a check's output. A check that only warns
54 * (additionalContext, no decision) lets the call run with its note.
55 */
56export function decisionOf(v: HookOut | undefined):
57  { deny: string } | { ask: string } | { additionalContext: string[] } | undefined {
58  const h = v?.hookSpecificOutput
59  const reason = String(h?.permissionDecisionReason ?? '')
60  if (h?.permissionDecision === 'deny' && reason) return { deny: reason }
61  if (h?.permissionDecision === 'ask' && reason) return { ask: reason }
62  const note = contextOf(v)
63  return note ? { additionalContext: [note] } : undefined
64}
65
66/** The check's additionalContext, if it gave one. */
67export function contextOf(v: HookOut | undefined): string | undefined {
68  const c = v?.hookSpecificOutput?.additionalContext
69  return typeof c === 'string' && c ? c : undefined
70}
71
72/** A Stop block reason, if the check asked to block the turn's end. */
73export function blockReason(v: HookOut | undefined): string | undefined {
74  return v?.decision === 'block' && typeof v.reason === 'string' && v.reason ? v.reason : undefined
75}
76
77/** The text a tool call's result carries, for checks that read it (PostToolUse-style). */
78export function resultText(ran: { text?: unknown; result?: unknown }): string {
79  if (typeof ran.text === 'string') return ran.text
80  try { return JSON.stringify(ran.result ?? '') } catch { return '' }
81}
82
83/** A toast's text: the first line, capped. */
84export function toastText(text: string): string {
85  return text.split('\n')[0]!.slice(0, 200)
86}
87
types/index.d.ts 13 lines
1/** mod name -> fingerprint of the code loaded at session start (see hooks/mod-freshness.py). */
2export type Fingerprints = Record<string, string>
3
4declare module 'claude-code' {
5  interface PluginState {
6    'mod-freshness': {
7      loaded: Fingerprints
8      /** Mods already reported stale this session (each is reported once). */
9      warned: string[]
10    }
11  }
12}
13