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…

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.
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.
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:
--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.--force-with-lease is fine.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.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.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 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.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.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.
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.
MIT. See LICENSE.
hooks/register.ts 131 lines1import { 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}
131types/index.d.ts 12 lines1export 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