SLOPSHOPPER

effort-gate

Denies a subagent or background session running Opus at xhigh or max effort unless the user said so

newguardprompt
A shopper browsing a rack in a slop shop
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 233 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const HIGH = new Set(['xhigh', 'max'])
4// Allowlist: only a model that names Sonnet, Haiku or Fable (alias or full id) is not Opus. Everything else
5// (opus, best, default, opusplan, an id we do not know) is held to the Opus rule.
6const isOpus = (m?: string) => !!m && (/opus/i.test(m) || !/sonnet|haiku|fable/i.test(m))
7
8// First `key: value` of the file's frontmatter, unquoted.
9function front(text: string, key: string) {
10  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? ''
11  const v = new RegExp(`^${key}` + String.raw`:\s*(.+?)\s*$`, 'm').exec(block)?.[1]
12  return v?.replace(/^["']|["']$/g, '')
13}
14
15// Project agents shadow user agents. A missing file is just "no frontmatter".
16async function agentFile(
17  $: EngineInterface,
18  agent: string,
19): Promise<{ model?: string; effort?: string }> {
20  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
21  const cwd = await $.session.cwd()
22  for (const dir of [`${cwd}/.claude/agents`, `${home}/.claude/agents`]) {
23    try {
24      const text = await $.fs.read(`${dir}/${agent}.md`)
25      if (typeof text === 'string' && text) return { model: front(text, 'model'), effort: front(text, 'effort') }
26    } catch {}
27  }
28  return {}
29}
30
31// ---- reading `claude ... --bg` out of a shell command ----
32
33type Tok = { text: string; quoted: boolean } // quoted: the word began inside quotes (a prompt, never a flag)
34
35// Splits a command into segments (at unquoted ; | & newline) of words (at unquoted whitespace).
36// ponytail: no heredocs, backticks, $(...) or backslash escapes outside quotes; an odd apostrophe swallows the rest, which only hides flags.
37export function split(cmd: string): { segs: Tok[][]; open: boolean } {
38  const segs: Tok[][] = [[]]
39  let cur = ''
40  let has = false
41  let startQ = false
42  let q: string | null = null
43  const word = () => {
44    if (has) segs[segs.length - 1]!.push({ text: cur, quoted: startQ })
45    cur = ''
46    has = false
47    startQ = false
48  }
49  for (let i = 0; i < cmd.length; i++) {
50    const c = cmd[i]!
51    if (q) {
52      if (c === q) q = null
53      else if (c === '\\' && q === '"' && cmd[i + 1] === '"') cur += cmd[++i]
54      else cur += c
55    } else if (c === '"' || c === "'") {
56      if (!has) startQ = true
57      has = true
58      q = c
59    } else if (c === '\n' || c === ';' || c === '|' || c === '&') {
60      word()
61      if (segs[segs.length - 1]!.length) segs.push([])
62    } else if (/\s/.test(c)) word()
63    else {
64      cur += c
65      has = true
66    }
67  }
68  word()
69  return { segs: segs.filter(s => s.length), open: q !== null }
70}
71
72const VALUE_FLAGS: Record<string, 'model' | 'effort' | 'agent' | 'advisor' | 'name'> = {
73  '--model': 'model',
74  '--effort': 'effort',
75  '--agent': 'agent',
76  '--advisor': 'advisor',
77  '--name': 'name',
78  '-n': 'name',
79}
80export type BgLaunch = Partial<Record<(typeof VALUE_FLAGS)[string], string>>
81
82const SHELL = /^(?:(?:ba|z|da|k|c)?sh|pwsh|powershell|cmd)(?:\.exe)?$/i
83
84// Every `claude ... --bg/--background` segment. `claude` must be the segment's command word (after FOO=bar
85// assignments; a path ending in claude or claude.exe is fine); flags are read only from unquoted words, and a
86// quoted word counts only as the value right after a flag that takes one. `sh -c "..."` is read inside.
87export function bgLaunches(cmd: string): BgLaunch[] {
88  const out: BgLaunch[] = []
89  for (const seg of split(cmd).segs) {
90    let i = 0
91    while (i < seg.length && !seg[i]!.quoted && /^[A-Za-z_]\w*=/.test(seg[i]!.text)) i++
92    const word = seg[i]?.text.split(/[\\/]/).pop() ?? ''
93    if (SHELL.test(word)) {
94      const k = seg.findIndex((t, n) => n > i && !t.quoted && /^(?:-[a-z]*c|-command|\/c)$/i.test(t.text))
95      if (k >= 0) {
96        const rest = seg.slice(k + 1)
97        out.push(...bgLaunches(rest.length === 1 ? rest[0]!.text : rest.map(t => (t.quoted ? JSON.stringify(t.text) : t.text)).join(' ')))
98      }
99      continue
100    }
101    if (!/^claude(?:\.(?:exe|cmd|ps1))?$/i.test(word)) continue
102    const found: BgLaunch = {}
103    let bg = false
104    for (let j = i + 1; j < seg.length; j++) {
105      const t = seg[j]!
106      if (t.quoted) continue
107      if (t.text === '--') break
108      const m = /^(--[a-z-]+|-n)(?:=(.*))?$/i.exec(t.text)
109      if (!m) continue
110      if (m[1] === '--bg' || m[1] === '--background') {
111        bg = true
112        continue
113      }
114      const key = VALUE_FLAGS[m[1]!.toLowerCase()]
115      if (!key) continue
116      let v = m[2]
117      const next = seg[j + 1]
118      if (v === undefined && next && (next.quoted || !next.text.startsWith('-'))) {
119        v = next.text
120        j++
121      }
122      if (v !== undefined) found[key] = v
123    }
124    if (bg) out.push(found)
125  }
126  return out
127}
128
129// The tokenizer is precise but cannot read every shell form (loops, wrappers, $( ), Start-Process, a stray apostrophe,
130// a launch inside a string handed to iex/eval/python -c). So a loose count on the text also looks for `claude ... --bg`;
131// more of those than the tokenizer parsed launches (or any, with an unclosed quote) means the command is refused as
132// unreadable. A prompt or commit message that merely mentions `claude --bg` trips it too; that is accepted.
133const CLAUDE_WORD = /(?<![A-Za-z0-9_.-])claude(?:\.(?:exe|cmd|ps1)|(?![\w.-]))(?=[\s\S]*?--(?:bg|background)\b)/gi
134
135export function analyze(cmd: string, tool: 'Bash' | 'PowerShell'): { launches: BgLaunch[]; unreadable: boolean } {
136  // A line continuation is `\` in Bash and a backtick in PowerShell; in the other shell it is just a character.
137  const joined = cmd.replace(tool === 'Bash' ? /\\\r?\n/g : /`\r?\n/g, ' ')
138  const launches = bgLaunches(joined)
139  // One normalized copy to count in: quotes deleted (cl"au"de), separators and newlines blanked. Every `claude` word
140  // with a `--bg` anywhere later counts (the lookahead consumes nothing, so they do not eat each other). Each parsed
141  // launch is one such word, so a hidden launch always makes the count exceed the parsed ones.
142  const flat = joined.replace(/["']/g, '').replace(/[;|&\r\n]/g, ' ')
143  const hits = flat.match(CLAUDE_WORD)?.length ?? 0
144  const unreadable = hits > launches.length || (split(joined).open && hits > 0)
145  return { launches, unreadable }
146}
147
148// A value the shell would expand or run: not what the gate can compare.
149const unresolvable = (v?: string) => !!v && /^[$%]|[(`]/.test(v)
150
151async function mainModel($: EngineInterface) {
152  try {
153    return await $.session.model()
154  } catch {
155    return 'opus' // not exposed: treat the inherited model as Opus
156  }
157}
158
159const approval = { plugin: 'effort-gate', key: 'approved' } as const
160
161const deny = (what: string, effort: string) => ({
162  decision: 'deny' as const,
163  reason: `effort-gate: Opus@${effort} for ${what} needs the user's explicit OK. Ask the user in chat; their reply must mention Opus and xhigh/max (e.g. 'ok opus xhigh'). Otherwise use opus@high or sonnet@xhigh.`,
164})
165
166// A guard that throws would otherwise be skipped and the call allowed.
167const failClosed = (_$: unknown, e: unknown, next: { called: boolean } & ((e: never) => unknown)) =>
168  next.called ? next(e as never) : { decision: 'deny' as const, reason: 'effort-gate: check failed' }
169
170export const register: Register = on => {
171  // Only the user's own typing (terminal or Remote Control) sets or clears the approval, until their next prompt.
172  on('prompt.submit', async ($, e, next) => {
173    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
174      const ok = /\bopus\b/i.test(e.text) && /\b(xhigh|x-high|extra[ -]?high|max)\b/i.test(e.text)
175      await $.state.set(approval, ok)
176    }
177    return next(e)
178  })
179
180  on('tool.check', { tool: 'Agent' }, async ($, e, next) => {
181    const i = (e.input ?? {}) as { model?: string; effort?: string; subagent_type?: string }
182    if (i.subagent_type === 'fork') return next(e) // forks inherit the parent; not gated
183    const def = i.subagent_type ? await agentFile($, i.subagent_type) : {}
184    let model = i.model ?? def.model
185    if (!model || model === 'inherit') model = await mainModel($)
186    const effort = (i.effort ?? def.effort)?.toLowerCase()
187    if (!(isOpus(model) && effort && HIGH.has(effort))) return next(e)
188    return (await $.state.get(approval)).value ? next(e) : deny('a subagent', effort)
189  }).catch(failClosed)
190
191  for (const tool of ['Bash', 'PowerShell'] as const) {
192    on('tool.check', { tool }, async ($, e, next) => {
193      const cmd = String((e.input as { command?: string })?.command ?? '')
194      const { launches, unreadable } = analyze(cmd, tool)
195      if (unreadable)
196        return {
197          decision: 'deny' as const,
198          reason: "effort-gate: can't read this claude --bg launch; run it as a plain `claude --bg --model X --effort Y ...` command.",
199        }
200      for (const f of launches) {
201        if (unresolvable(f.model) || unresolvable(f.effort))
202          return {
203            decision: 'deny' as const,
204            reason: "effort-gate: can't resolve the --model/--effort of this claude --bg launch (variable or substitution); write the values out.",
205          }
206        if (f.agent && /[\/\\]|\.\./.test(f.agent))
207          return {
208            decision: 'deny' as const,
209            reason: 'effort-gate: --agent must be a plain agent name (no path), so its frontmatter can be checked.',
210          }
211        const def = f.agent ? await agentFile($, f.agent) : {}
212        const model = f.model ?? def.model
213        // No model (or `inherit`) means the worker silently inherits Opus: make the orchestrator pick one.
214        if (!model || model === 'inherit')
215          return {
216            decision: 'deny' as const,
217            reason: 'effort-gate: a background session needs an explicit --model (and --effort), or an --agent whose frontmatter sets them. Without it the worker inherits Opus.',
218          }
219        const effort = (f.effort ?? def.effort)?.toLowerCase()
220        // An Opus session with no effort of its own takes it from settings, which can say xhigh/max.
221        if (isOpus(model) && !effort)
222          return {
223            decision: 'deny' as const,
224            reason: 'effort-gate: an Opus background session needs an explicit --effort (or an --agent whose frontmatter sets it); the default can come from settings.',
225          }
226        if (isOpus(model) && effort && HIGH.has(effort) && !(await $.state.get(approval)).value)
227          return deny('a background session', effort)
228      }
229      return next(e)
230    }).catch(failClosed)
231  }
232}
233
types/index.d.ts 11 lines
1export type EffortGateApproval = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'effort-gate': {
6      /** The user's latest own prompt named Opus and xhigh/max. */
7      approved: boolean
8    }
9  }
10}
11