SLOPSHOPPER

usage-pacer

Shows session-limit usage in the status line, warns once at 80% and 95% of each window, and can run a /handoff command before the 5h limit in the session you…

newtoaststatustimeragents
A shopper browsing a rack in a slop shop
README

Claude Code mods

Six small mods for Claude Code, written in TypeScript against its plugin hooks API. They add guard rails on tool calls, status line items, panes and slash commands: blocking risky git and shell commands, tracking your pull requests, gathering a standup, pacing session limits, showing which repo Claude is in, and keeping the Mac awake while work runs. Each mod is its own plugin, so you can install only the ones you want.

Install

Add the marketplace from GitHub or from a local clone:

/plugin marketplace add AbdAlrahmanAlQaroot/claude-code-mods
/plugin marketplace add ~/path/to/claude-code-mods

Then install the mods you want:

/plugin install rules-guard@claude-code-mods
/plugin install pr-radar@claude-code-mods

The same works from a shell with claude plugin marketplace add <source> and claude plugin install <name>@claude-code-mods. Options are set with /plugin (pick the plugin, then configure) or claude plugin configure <name>. Restart the session after installing or changing options.

pr-radar and standup call the GitHub CLI, so gh must be installed and logged in. stay-awake and the pr-radar desktop notification are macOS only.

rules-guard

Checks Bash, Write and Edit calls before they run and holds the ones that break a rule. Each block names the rule, the problem, where it was found, the fix, and when to ask the user, so Claude can correct itself and retry.

Always on:

  • No direct push to a protected branch, including --all, --mirror and a bare git push from a protected branch. A bare push whose branch cannot be told is held until the branch is named.
  • No plain force push. --force-with-lease is fine.
  • No kubectl or aws calls aimed at production (a context, cluster or profile with "prod" in its name).

Optional (off by default):

  • noAiAttribution: block AI attribution lines (co-author trailers, session links, "generated with" lines) in commits, PRs, comments and files.
  • noTestPlan: block a "Test plan" section in PR descriptions.
  • prBulletsOnly: PR descriptions must be 20 lines or fewer of short bullet points, with no prose paragraphs.
  • noCodeComments: block new code comments in source files inside a git repo. Tool directives such as @ts- or eslint- lines and shebangs are allowed.
  • noTestFiles: block a git commit that would include test files.
  • noSlackSend: block Claude from sending or scheduling Slack messages. Drafts are allowed.
  • liquibaseAuthor: the only author allowed on Liquibase changesets. Empty turns the check off.
  • vpnProcess: process name that runs while your VPN is up, such as openvpn. When set, kubectl calls are held while it is not running.
  • awsSsoCheck: hold kubectl and aws calls when no AWS SSO session in ~/.aws/sso/cache is fresh.

Other options:

  • protectedBranches: comma-separated branch names. Default main,master.

pr-radar

Keeps the state of your open GitHub pull requests across every repo: review decision, CI checks, unresolved threads, conflicts, and PRs merged in the last three days. It shows a summary in the status line, refreshes every five minutes and after gh pr or git push commands, and tells you when a PR is approved, gets changes requested, starts or stops failing, gets new comments, merges or closes.

  • /prs lists your PRs with links. /prs pane opens a live pane. /prs refresh fetches now.
  • /unreleased compares main (or master) with your integration branch in each repo and lists the PRs and commits not yet released.
  • Claude gets two tools, my_prs and my_unreleased, and prompts about PRs carry the live state as context.

Options:

  • alerts: toast and macOS notification on PR changes. Default true.
  • sound: play a sound with the notification. Default true.
  • repos: comma-separated owner/repo list for /unreleased. Default empty, which uses only repos where you have a PR into the integration branch.
  • integrationBranch: the branch work merges into before a release. Default staging.
  • login: your GitHub login, used to mark your own commits in /unreleased. Default empty, which reads it from gh api user.

standup

/standup opens a pane with what you did on a work day: PRs you opened, merged and reviewed, the commits you made, and the Claude Code sessions you worked in, grouped by repo. With no argument it shows the previous work day. It also accepts today, yesterday or a date like 2026-10-06. The Draft button asks Claude to write the update.

Options:

  • repos: comma-separated local repo paths to read commits from, including unpushed branches. Paths can be absolute, start with ~/, or be relative to your home folder. Default empty, which uses GitHub search only.
  • utcOffset: your time zone as an offset from UTC, such as +03:00. Default +00:00.
  • workDays: comma-separated work days. Default Mon,Tue,Wed,Thu,Fri.
  • draftCommand: a slash command the Draft button runs with the day as its argument. Default empty, which sends Claude a prompt with the day's activity instead.

usage-pacer

Shows session-limit usage in the status line with the time until each window resets, and shows a toast once at 80% and once at 95% of each window.

Options:

  • sonnetSubagentsAbove: when usage of any window is above this percent, general-purpose subagents with no model of their own run on Sonnet. Default 0, which turns it off.
  • handoffAt: when the 5-hour window reaches this percent, run your own /handoff command once in the session you are actively working in. Default 0, which turns it off.

repo-context

Shows the repo and branch Claude is working in on the status line, following cd and git -C in its commands. It also names a new session from its first real prompt, using a short Haiku call.

No options.

stay-awake

Keeps the Mac from idle-sleeping while a turn or a background agent runs, using caffeinate, and lets it sleep again once everything is done.

No options.

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 131 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { SessionActivity, UsageWarnings } from '../types'
5
6const LEVELS = [95, 80]
7const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
8const REFRESH_MS = 60_000
9const HANDOFF_TURNS = 5
10const HANDOFF_RECENT_MS = 20 * 60_000
11
12const warned = atom({ plugin: 'usage-pacer', key: 'warned' } as const, {} as UsageWarnings)
13const activity = atom({ plugin: 'usage-pacer', key: 'activity' } as const, { turns: 0, lastAt: 0, isInteractive: false } as SessionActivity)
14const handedOff = atom({ plugin: 'usage-pacer', key: 'handedOff' } as const, '')
15
16const label = (kind: string) => LABELS[kind] ?? kind.replaceAll('_', ' ')
17
18const percent = (limit: SessionRateLimit) => Math.round(limit.percentUsed)
19
20const timeLeft = (resetsAt: string | undefined, now: number) => {
21  if (resetsAt === undefined) return undefined
22  const minutes = Math.ceil((Date.parse(resetsAt) - now) / 60_000)
23  if (!(minutes > 0)) return undefined
24  if (minutes < 60) return `${minutes}m`
25  const hours = Math.floor(minutes / 60)
26  if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
27  return `${Math.floor(hours / 24)}d${hours % 24}h`
28}
29
30const statusText = (limits: readonly SessionRateLimit[], now: number) =>
31  limits
32    .map(limit => {
33      const left = timeLeft(limit.resetsAt, now)
34      return `${label(limit.kind)} ${percent(limit)}%${left ? ` (resets in ${left})` : ''}`
35    })
36    .join(' · ')
37
38const warningText = (limit: SessionRateLimit, level: number, now: number) => {
39  const head = `${label(limit.kind)} usage ${percent(limit)}%`
40  if (level < 95) return `${head} — consider /model sonnet for bulk work or scans`
41  const left = timeLeft(limit.resetsAt, now)
42  return `${head} — almost at the limit${left ? `, resets in ${left}` : ''}`
43}
44
45const isActive = (seen: SessionActivity, now: number) =>
46  seen.isInteractive && seen.turns >= HANDOFF_TURNS && now - seen.lastAt <= HANDOFF_RECENT_MS
47
48const startHandoff = ($: EngineInterface) => {
49  $.clock.after(0, () => {
50    void $.command.run({ command: 'handoff', args: '' }).catch(() => $.ui.toast('usage-pacer: /handoff did not start, run it yourself'))
51  })
52}
53
54const pace = async ($: EngineInterface, limits: readonly SessionRateLimit[], handoffAt: number) => {
55  const now = await $.clock.now()
56  $.ui.status(limits.length === 0 ? undefined : statusText(limits, now))
57
58  const before = await read($, warned)
59  const toasts: { kind: string; text: string }[] = []
60  const after: UsageWarnings = {}
61
62  for (const limit of limits) {
63    const resetsAt = limit.resetsAt ?? ''
64    const kept = before[limit.kind]
65    const levels = kept !== undefined && kept.resetsAt === resetsAt ? kept.levels : []
66    const reached = LEVELS.find(level => limit.percentUsed >= level)
67    if (reached !== undefined && !levels.includes(reached)) toasts.push({ kind: limit.kind, text: warningText(limit, reached, now) })
68    after[limit.kind] = {
69      resetsAt,
70      levels: reached === undefined ? levels : [...new Set([...levels, ...LEVELS.filter(level => level <= reached)])],
71    }
72  }
73
74  await update($, warned, () => after)
75
76  const five = limits.find(limit => limit.kind === 'five_hour')
77  if (handoffAt > 0 && five !== undefined && five.percentUsed >= handoffAt) {
78    const window = five.resetsAt ?? 'unknown'
79    if ((await read($, handedOff)) !== window && isActive(await read($, activity), now)) {
80      await update($, handedOff, () => window)
81      const text = `5h usage ${percent(five)}% — writing a handoff now so you can switch accounts`
82      const at = toasts.findIndex(toast => toast.kind === 'five_hour')
83      if (at >= 0) toasts[at] = { kind: 'five_hour', text }
84      else toasts.push({ kind: 'five_hour', text })
85      startHandoff($)
86    }
87  }
88
89  for (const toast of toasts) $.ui.toast(toast.text, { timeoutMs: 10_000 })
90}
91
92export const register: Register = (on, options) => {
93  const above = Number(options.sonnetSubagentsAbove ?? 0)
94  const handoffAt = Number(options.handoffAt ?? 0)
95
96  on('session.start', async ($, e, next) => {
97    const started = await next(e)
98    await update($, activity, seen => ({ ...seen, isInteractive: e.isInteractive }))
99    $.clock.every(REFRESH_MS, () => {
100      void $.session.usage().then(usage => pace($, usage.rateLimits, handoffAt))
101    })
102    return started
103  })
104
105  on('turn.complete', async ($, e, next) => {
106    const done = await next(e)
107    if (e.agentId === undefined) {
108      const now = await $.clock.now()
109      await update($, activity, seen => ({ ...seen, turns: seen.turns + 1, lastAt: now }))
110    }
111    return done
112  })
113
114  on('session.measure', async ($, e, next) => {
115    if (e.changed.includes('rateLimits')) await pace($, e.rateLimits, handoffAt)
116    return next(e)
117  })
118
119  if (above > 0) {
120    on('agent.spawn', async ($, e, next) => {
121      const isPinned = e.fork || e.workflow !== undefined || e.isTeammate === true || e.model !== undefined
122      if (isPinned || e.subagentType !== 'general-purpose' || !/opus/i.test(e.parentModel)) return next(e)
123      const { rateLimits } = await $.session.usage()
124      const peak = Math.max(0, ...rateLimits.map(limit => limit.percentUsed))
125      if (peak <= above) return next(e)
126      $.ui.log(`usage-pacer: "${e.description}" runs on sonnet, usage at ${Math.round(peak)}%`)
127      return next({ ...e, model: 'sonnet' })
128    })
129  }
130}
131
types/index.d.ts 12 lines
1export type UsageWarning = { resetsAt: string; levels: number[] }
2
3export type UsageWarnings = Record<string, UsageWarning>
4
5export type SessionActivity = { turns: number; lastAt: number; isInteractive: boolean }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-pacer': { warned: UsageWarnings; activity: SessionActivity; handedOff: string }
10  }
11}
12