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

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 233 lines1import 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}
233types/index.d.ts 11 lines1export 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