Resumes the session and its idle workers when a usage limit resets, and gives Claude the current 5h and weekly usage

Claude Code mods for running orchestrator and worker sessions. Built and tested on Claude Code v2.1.289.
| Mod | What it does |
|---|---|
limit-resume | When the 5-hour limit resets, sends "Continue" to the session and nudges idle workers. A dim usage tail on the prompt hint line from 80%, /limits, and a one-line usage note for Claude each turn. |
workers | /workers opens a pane with every worker: state, branch, commits ahead of main, changed files, last message, and who needs you. /workers all shows every session. |
identity-keeper | Remembers the session name (from /rename or "You are <name>" in the first prompt), restores it after a restart, and gives Claude a short role card after each compaction. /identity, /identity forget. |
proof-gate | When a worker reports done without screenshots, asks it for them. When proof arrives, sends the files to you and shows a band above the prompt with Approve and Ask changes. |
effort-gate | Denies a subagent or claude --bg session that runs Opus at xhigh or max effort unless you said so: your own next prompt (terminal or Remote Control) must mention Opus and xhigh/max, e.g. "ok opus xhigh". The OK lasts until your next prompt. Sonnet, Haiku, and the main session are not gated. Also denies a claude --bg with no --model (or --agent that sets one), since it would silently run Opus, and an Opus one with no --effort (settings could raise it). It fails closed on claude --bg text it cannot read, so a message that merely mentions claude --bg is denied too. It only sees the literal command text: variables, aliases or functions, scripts written then run, and xargs get through. |
route-ledger | Records every subagent and claude --bg launch (model, effort, agent, label, never the prompt) and, for subagents (background ones too), ok/error, duration and tokens. A relaunch with the same label in the same session marks the earlier run retried or escalated. /routing-review [days] prints launches, outcomes, retries, escalations and median tokens and time per model@effort. Background sessions are launch-only: their outcome is not visible to the mod. |
config-sync | At session start (at most every 10 minutes per device) pulls ~/.claude/shared (the claude-config repo) with --ff-only. Tells you when the pull fails, when there are unpushed local changes, or when settings.shared.json changed; /config-sync apply then runs the installer, /config-sync shows the status. Skips private CLAUDE_CONFIG_DIR setups. |
savvy-progress | A progress bar above the prompt and /agents-info, a panel of every subagent with model, context, cost and time. scout, builder and reviewer get their own crabs: a ranger with binoculars, a builder with a hammer, a reviewer in a mortarboard with a clipboard. The bar's crab is the orchestrator, a conductor with a baton. Also lists related background sessions and draws the crabs as half-block text in the terminal. Copy of johnnyvizz/claude-kit (MIT). |
cache-tax | Keeps the one-hour prompt cache warm: every session starts an 8-hour keepwarm window that pings after 50 idle minutes, and a cold send shows its rewrite cost. Changed from upstream: keepwarm is always on for 8h, and the guard warns instead of dropping the message. /keepwarm off turns it off on this device. Pings stop after 3h idle and while weekly usage is 75% or more. Pings cost usage. Copy of karanb192/cache-tax (MIT). |
claude plugin marketplace add Berkay2002/berkays-mods
claude plugin install limit-resume@berkays-mods
claude plugin install workers@berkays-mods
claude plugin install identity-keeper@berkays-mods
claude plugin install proof-gate@berkays-mods
claude plugin install effort-gate@berkays-mods
claude plugin install route-ledger@berkays-mods
claude plugin install config-sync@berkays-mods
claude plugin install savvy-progress@berkays-mods
claude plugin install cache-tax@berkays-mods
Plugins carry no version, so each commit is a new version:
claude plugin marketplace update berkays-mods
claude plugin update workers@berkays-mods # and the others
Then run /reload-plugins in open sessions.
Work against the checkout, not the installed copy:
claude --plugin-dir plugins/workers
claude plugin validate plugins/workers
(cd plugins/workers && claude plugin test)
MIT
hooks/register.ts 302 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4const tail = atom({ plugin: 'limit-resume', key: 'tail' } as const, '')
5
6const RESUME_TEXT = 'Continue. The usage limit has reset.'
7const NUDGE_TEXT = 'Usage limit has reset. Continue your task.'
8// Wait a minute past the reset so the first request lands in the new window.
9const GRACE_MS = 60_000
10// When a turn dies on a rate limit but no window reports a reset time, try again this much later.
11const UNKNOWN_RESET_MS = 30 * 60_000
12// The hint tail stays empty below this 5h percentage.
13const WARN_PERCENT = 80
14const MINUTE = 60_000
15const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
16
17type Pending = { cutAt: number; resetsAt: number; isGuessed: boolean }
18type Saved = { pending: Pending | null; handled: number[]; workers: string[] }
19type Agent = { pid?: number; sessionId?: string; name?: string; status?: string }
20
21// Module state: starts over on a reload; what must survive one lives in $.store.
22let sessionId = ''
23let limits: readonly SessionRateLimit[] = []
24let saved: Saved = { pending: null, handled: [], workers: [] }
25let timer: Timer | undefined
26let ticker: Timer | undefined
27let isBusy = false
28let lastAnswerAt = 0
29
30function key() {
31 return `session:${sessionId}`
32}
33
34function save($: EngineInterface) {
35 return $.store.set(key(), saved)
36}
37
38function hhmm(ms: number) {
39 const d = new Date(ms)
40 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
41}
42
43// "15:04" today, "Tue 09:00" on another day.
44function when(ms: number, now: number) {
45 const isToday = new Date(ms).toDateString() === new Date(now).toDateString()
46 return isToday ? hhmm(ms) : `${DAYS[new Date(ms).getDay()]} ${hhmm(ms)}`
47}
48
49function duration(ms: number) {
50 const minutes = Math.max(1, Math.ceil(ms / MINUTE))
51 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
52}
53
54function plural(n: number, word: string) {
55 return `${n} ${word}${n === 1 ? '' : 's'}`
56}
57
58function resetOf(row: SessionRateLimit | undefined) {
59 const t = row?.resetsAt ? Date.parse(row.resetsAt) : NaN
60 return Number.isNaN(t) ? null : t
61}
62
63function resumeAt(p: Pending) {
64 return p.resetsAt + GRACE_MS
65}
66
67// Under an hour away counts down; further out reads as a clock time.
68function resumeLabel(p: Pending, now: number) {
69 const left = resumeAt(p) - now
70 if (p.isGuessed) return `retries ~${hhmm(resumeAt(p))}`
71 return left < 60 * MINUTE ? `resumes in ${duration(left)}` : `resumes ${when(resumeAt(p), now)}`
72}
73
74async function refreshStatus($: EngineInterface) {
75 const now = await $.clock.now()
76 const p = saved.pending
77 if (p) {
78 const n = saved.workers.length
79 // A pause is a real alert: it keeps the pinned notice, and the hint tail stays empty.
80 $.ui.status(`⏸ limit · ${resumeLabel(p, now)}${n ? ` · ${plural(n, 'worker')}` : ''}`)
81 await update($, tail, () => '')
82 return
83 }
84 // Clears a pause notice left from before the resume.
85 $.ui.status(undefined)
86 const five = limits.find(r => r.kind === 'five_hour')
87 if (!five || five.percentUsed < WARN_PERCENT) {
88 await update($, tail, () => '')
89 return
90 }
91 const reset = resetOf(five)
92 await update($, tail, () => `5h ${five.percentUsed}%${reset === null ? '' : ` · resets ${when(reset, now)}`}`)
93}
94
95async function schedule($: EngineInterface) {
96 const p = saved.pending
97 if (!p) return
98 timer?.cancel()
99 ticker?.cancel()
100 const delay = Math.max(0, resumeAt(p) - (await $.clock.now()))
101 timer = $.clock.after(delay, () => void fire($))
102 ticker = $.clock.every(MINUTE, () => void refreshStatus($))
103 await refreshStatus($)
104}
105
106// The window that is used up and resets last decides when work can go on.
107function exhaustedReset(rows: readonly SessionRateLimit[]) {
108 const times = rows
109 .filter(r => r.percentUsed >= 100)
110 .map(resetOf)
111 .filter((t): t is number => t !== null)
112 return times.length ? Math.max(...times) : null
113}
114
115async function markCut($: EngineInterface, isRateLimitError: boolean) {
116 const now = await $.clock.now()
117 let resetsAt = exhaustedReset(limits)
118 let isGuessed = false
119 if (resetsAt === null) {
120 if (!isRateLimitError) return
121 const fiveHour = resetOf(limits.find(r => r.kind === 'five_hour'))
122 if (fiveHour !== null && fiveHour > now) {
123 resetsAt = fiveHour
124 } else {
125 resetsAt = now + UNKNOWN_RESET_MS
126 isGuessed = true
127 }
128 }
129 if (saved.handled.includes(resetsAt)) return
130 if (saved.pending && (saved.pending.resetsAt === resetsAt || (!isGuessed && saved.pending.resetsAt > resetsAt))) return
131 const isFirstHit = saved.pending === null
132 saved = { ...saved, pending: { cutAt: now, resetsAt, isGuessed } }
133 await save($)
134 await schedule($)
135 if (isFirstHit) {
136 $.ui.toast(`Usage limit reached · ${isGuessed ? 'retrying around' : 'resuming at'} ${hhmm(resetsAt + GRACE_MS)}`)
137 }
138}
139
140async function listAgents($: EngineInterface): Promise<Agent[]> {
141 try {
142 const { exitCode, stdout } = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 15_000 })
143 if (exitCode !== 0) return []
144 const parsed: unknown = JSON.parse(stdout)
145 return Array.isArray(parsed) ? (parsed as Agent[]) : []
146 } catch {
147 return []
148 }
149}
150
151// A recipient is spelled as SendMessage spells it: a session name, a session id, or a uds:/.../<pid>.sock address.
152function findAgent(agents: Agent[], address: string) {
153 const pid = /cc-socks\/(\d+)\.sock/.exec(address)?.[1]
154 return agents.find(
155 a => a.name === address || a.sessionId === address || (pid !== undefined && String(a.pid) === pid),
156 )
157}
158
159async function fire($: EngineInterface) {
160 const p = saved.pending
161 if (!p || saved.handled.includes(p.resetsAt)) return
162 timer = undefined
163 ticker?.cancel()
164 ticker = undefined
165 saved = { ...saved, pending: null, handled: [...saved.handled, p.resetsAt].slice(-20) }
166 await save($)
167
168 // Someone already got this session going again after the cut: leave it alone.
169 const isResumed = isBusy || lastAnswerAt > p.cutAt
170 if (!isResumed) {
171 void $.prompt.submit({ text: RESUME_TEXT }).catch(() => undefined)
172 }
173
174 let nudged = 0
175 if (saved.workers.length) {
176 const agents = await listAgents($)
177 const seen = new Set<string>()
178 for (const address of saved.workers) {
179 const agent = findAgent(agents, address)
180 if (!agent?.sessionId || agent.sessionId === sessionId || seen.has(agent.sessionId)) continue
181 seen.add(agent.sessionId)
182 if (agent.status !== 'idle') continue
183 const sent = await $.session.send({ to: { sessionId: agent.sessionId }, text: NUDGE_TEXT }).catch(() => null)
184 if (sent?.isDelivered) nudged += 1
185 }
186 }
187
188 await refreshStatus($)
189 const head = isResumed ? 'Already running' : 'Resumed'
190 $.ui.toast(nudged ? `${head} · ${plural(nudged, 'worker')} nudged` : head)
191}
192
193async function addWorker($: EngineInterface, address: string) {
194 if (!address || saved.workers.includes(address)) return
195 saved = { ...saved, workers: [...saved.workers, address].slice(-30) }
196 await save($)
197 if (saved.pending) await refreshStatus($)
198}
199
200function usageLine(rows: readonly SessionRateLimit[]) {
201 const five = rows.find(r => r.kind === 'five_hour')
202 const week = rows.find(r => r.kind === 'seven_day')
203 if (!five && !week) return null
204 const parts: string[] = []
205 if (five) {
206 const reset = resetOf(five)
207 parts.push(`5h ${five.percentUsed}%${reset === null ? '' : ` (resets ${hhmm(reset)})`}`)
208 }
209 if (week) parts.push(`week ${week.percentUsed}%`)
210 return `Usage: ${parts.join(', ')}`
211}
212
213function limitsReport(rows: readonly SessionRateLimit[], now: number) {
214 const lines: string[] = []
215 for (const [kind, label] of [['five_hour', '5h'], ['seven_day', 'Week']] as const) {
216 const row = rows.find(r => r.kind === kind)
217 if (!row) continue
218 const reset = resetOf(row)
219 lines.push(`${label} ${row.percentUsed}%${reset === null ? '' : ` · resets ${when(reset, now)}`}`)
220 }
221 if (!lines.length) lines.push('No usage reading yet')
222 const p = saved.pending
223 const n = saved.workers.length
224 lines.push(
225 p
226 ? `⏸ ${resumeLabel(p, now)}${n ? ` · ${plural(n, 'worker')} to nudge` : ''}`
227 : `No resume pending${n ? ` · ${plural(n, 'worker')} known` : ''}`,
228 )
229 return lines.join('\n')
230}
231
232export const register: Register = on => {
233 on('session.start', async ($, e, next) => {
234 sessionId = await $.session.id()
235 const stored = (await $.store.get(key())) as Saved | undefined
236 if (stored && Array.isArray(stored.handled) && Array.isArray(stored.workers)) saved = stored
237 limits = (await $.session.usage()).rateLimits
238 await $.command.register({ name: 'limits', description: 'Show usage windows and any pending resume', immediate: true })
239 if (saved.pending && !saved.handled.includes(saved.pending.resetsAt)) await schedule($)
240 else await refreshStatus($)
241 return next(e)
242 })
243
244 // The 5h reading rides the hint line as a dim tail, after any tail another hook already set.
245 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
246 const line = await read($, tail)
247 if (!line) return next(e)
248 return next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${line}` : line } })
249 })
250
251 on('command.run', { command: 'limits' }, async $ => {
252 limits = (await $.session.usage()).rateLimits
253 return { text: limitsReport(limits, await $.clock.now()) }
254 })
255
256 on('session.measure', async ($, e, next) => {
257 limits = e.rateLimits
258 if (e.changed.includes('rateLimits')) {
259 await markCut($, false)
260 if (!saved.pending) await refreshStatus($)
261 }
262 return next(e)
263 })
264
265 on('classic.StopFailure', async ($, e, next) => {
266 if (e.error === 'rate_limit') await markCut($, true)
267 return next(e)
268 })
269
270 on('turn.start', ($, e, next) => {
271 isBusy = true
272 return next(e)
273 })
274
275 on('turn.complete', async ($, e, next) => {
276 if (e.agentId === undefined) {
277 isBusy = false
278 if (e.reason === 'answer') lastAnswerAt = await $.clock.now()
279 }
280 return next(e)
281 })
282
283 on('session.send', async ($, e, next) => {
284 const result = await next(e)
285 if (e.origin.kind === 'model' && e.agentId === undefined && result.isDelivered) await addWorker($, e.to)
286 return result
287 })
288
289 on('session.receive', async ($, e, next) => {
290 // Peer deliveries carry the sender in their envelope; best effort, the text format is the engine's.
291 const from = e.agentId === undefined ? /from="([^"]+)"/.exec(e.text)?.[1] : undefined
292 if (from) await addWorker($, from)
293 return next(e)
294 })
295
296 on('prompt.submit', async ($, e, next) => {
297 if (e.turnId !== undefined) return next(e)
298 const line = usageLine((await $.session.usage()).rateLimits)
299 return line ? next({ ...e, context: [...(e.context ?? []), line] }) : next(e)
300 })
301}
302types/index.d.ts 12 lines1/** The 5h usage line shown dim at the end of the prompt hint. */
2export type HintTail = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 'limit-resume': {
7 /** The 5h usage line for the prompt hint's tail; empty while below the warning level or paused. */
8 tail: string
9 }
10 }
11}
12