SLOPSHOPPER

limit-resume

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

newspinnercommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-resume
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /limits ⎿ limit-resume: 5h 31% ⎿ limit-resume: No resume pending ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

berkays-mods

Claude Code mods for running orchestrator and worker sessions. Built and tested on Claude Code v2.1.289.

ModWhat it does
limit-resumeWhen 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-keeperRemembers 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-gateWhen 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-gateDenies 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-ledgerRecords 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-syncAt 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-progressA 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-taxKeeps 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).

Install

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

Update

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.

Develop

Work against the checkout, not the installed copy:

claude --plugin-dir plugins/workers
claude plugin validate plugins/workers
(cd plugins/workers && claude plugin test)

License

MIT

Source 2 files
hooks/register.ts 302 lines
1import { 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}
302
types/index.d.ts 12 lines
1/** 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