SLOPSHOPPER

proof-gate

Asks a worker that reports done for screenshots or video, sends the proof to you, and shows an approval band

newbandspinnertoastprocess
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.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ProofGatePending } from '../types'
5
6const pending = atom({ plugin: 'proof-gate', key: 'pending' } as const, [] as ProofGatePending[])
7const asked = atom({ plugin: 'proof-gate', key: 'asked' } as const, [] as string[])
8const ASKED_KEEP = 200
9const BAND_ROWS = 3
10const STATUS_NAMES = 3
11
12export const REQUEST =
13  'Before I review: send screenshots (desktop and mobile width) or a short video showing the change, as file paths.'
14
15const ENVELOPE = /<cross-session-message\b([^>]*)>([\s\S]*?)(?:<\/cross-session-message>|$)/
16const DONE =
17  /\b(is done|done:|done\.|i'?m done|all done|committed|finished|ready to (merge|review)|ready for review|merged (main )?into my branch)\b/i
18const NOT_DONE = /\b(not (yet )?(done|finished|committed)|blocked|need a decision)\b/i
19const MEDIA = /(?:https?:\/\/|~\/|\/|\.\/)?[\w@%+.\/~-]*\.(?:png|jpe?g|gif|webp|mp4|mov|webm)\b/gi
20const SHOTS_DIR = /(?:^|[\s`'"(\/])(shots|screenshots)\//i
21
22export type Sender = { name: string; address: string }
23
24/** The sender and body of a cross-session delivery, or null when it names no sender. */
25export function parse(text: string): (Sender & { body: string }) | null {
26  const m = ENVELOPE.exec(text)
27  if (!m) return null
28  const attrs = m[1] ?? ''
29  const address = /\bfrom="([^"]+)"/.exec(attrs)?.[1]
30  if (!address) return null
31  const name = /\bfrom-name="([^"]+)"/.exec(attrs)?.[1] ?? address
32  return { name, address, body: m[2] ?? '' }
33}
34
35export function proofPaths(body: string): string[] {
36  return [...new Set(body.match(MEDIA) ?? [])].filter(p => !/^https?:/i.test(p))
37}
38
39export function hasProof(body: string): boolean {
40  return (body.match(MEDIA) ?? []).length > 0 || SHOTS_DIR.test(body)
41}
42
43export function readsDone(body: string): boolean {
44  return DONE.test(body) && !NOT_DONE.test(body)
45}
46
47/**
48 * Display names: a dash-ended prefix every name shares is dropped
49 * (brf-app-compare, brf-app-charts -> compare, charts); a lone name loses
50 * its leading project segments (brf-app-compare -> compare).
51 */
52export function shortNames(names: readonly string[]): string[] {
53  if (names.length === 0) return []
54  if (names.length === 1) {
55    const only = names[0] ?? ''
56    const tail = only.slice(only.lastIndexOf('-') + 1)
57    return [tail.length >= 3 ? tail : only]
58  }
59  let prefix = names[0] ?? ''
60  for (const n of names) while (!n.startsWith(prefix)) prefix = prefix.slice(0, -1)
61  prefix = prefix.slice(0, prefix.lastIndexOf('-') + 1)
62  return names.map(n => (n.length > prefix.length ? n.slice(prefix.length) : n))
63}
64
65export function statusLine(list: readonly ProofGatePending[]): string | undefined {
66  if (list.length === 0) return undefined
67  const short = shortNames(list.map(p => p.name))
68  const shown = short.slice(0, STATUS_NAMES).join(', ')
69  const more = short.length > STATUS_NAMES ? ` +${short.length - STATUS_NAMES}` : ''
70  return list.length === 1 ? `${shown} to approve` : `${list.length} to approve · ${shown}${more}`
71}
72
73export function ago(ms: number): string {
74  const m = Math.max(0, Math.round(ms / 60000))
75  if (m < 1) return 'just now'
76  if (m < 60) return `${m}m ago`
77  const h = Math.round(m / 60)
78  return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
79}
80
81function messageKey(s: Sender, body: string): string {
82  const commit = /\b(?:commit|at)\s+`?([0-9a-f]{7,40})\b/i.exec(body)?.[1]
83  if (commit) return `${s.address}#${commit}`
84  let h = 5381
85  for (let i = 0; i < body.length; i++) h = ((h << 5) + h + body.charCodeAt(i)) | 0
86  return `${s.address}#${(h >>> 0).toString(36)}`
87}
88
89async function sendFiles($: EngineInterface, s: Sender, paths: string[]): Promise<number> {
90  const home = await $.env.get('HOME')
91  const files = paths
92    .map(p => (p.startsWith('~/') && home ? home + p.slice(1) : p))
93    .filter(p => p.startsWith('/'))
94  if (files.length === 0) return 0
95  try {
96    const tools = await $.tool.list()
97    if (!tools.some(t => t.name === 'SendUserFile')) return 0
98    const r = await $.tool.call({
99      tool: 'SendUserFile',
100      files,
101      caption: `${shortNames([s.name])[0]} · proof for review`,
102      status: 'proactive',
103    })
104    return r.isError ? 0 : files.length
105  } catch {
106    return 0
107  }
108}
109
110/**
111 * A worker is a local session running in a linked git worktree (where /orchestrate puts them).
112 * Keeps an orchestrator's own messages to a worker from reading as "done" inside that worker.
113 */
114async function isWorker($: EngineInterface, name: string): Promise<boolean> {
115  try {
116    const r = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 10_000 })
117    if (r.exitCode !== 0) return false
118    const list: unknown = JSON.parse(r.stdout)
119    const cwd = Array.isArray(list) ? list.find(a => a?.name === name)?.cwd : undefined
120    if (typeof cwd !== 'string') return false
121    const g = await $.process.run(
122      ['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'],
123      { timeoutMs: 5000 },
124    )
125    const [dir, common] = g.stdout.trim().split(/\r?\n/)
126    return g.exitCode === 0 && !!dir && !!common && dir !== common
127  } catch {
128    return false
129  }
130}
131
132export const register: Register = on => {
133  on('session.receive', async ($, e, next) => {
134    const queued = await next(e)
135    const msg = parse(e.text)
136    if (!msg) return queued
137    const { body } = msg
138    const short = shortNames([msg.name])[0]
139    const isProof = hasProof(body)
140    if (!isProof && !readsDone(body)) return queued
141    if (!(await isWorker($, msg.name))) return queued
142
143    if (isProof) {
144      const paths = proofPaths(body)
145      const at = await $.clock.now()
146      const entry: ProofGatePending = { name: msg.name, address: msg.address, files: paths.length, at }
147      await update($, pending, l => [...l.filter(p => p.address !== msg.address), entry])
148      await sendFiles($, msg, paths)
149      const what = paths.length === 1 ? '1 file' : paths.length ? `${paths.length} files` : 'a folder'
150      $.ui.toast(`${short} sent proof · ${what}`)
151      return queued
152    }
153
154    const key = messageKey(msg, body)
155    if ((await read($, asked)).includes(key)) return queued
156    await update($, asked, l => [...l, key].slice(-ASKED_KEEP))
157
158    const { isDelivered } = await $.session.send({ to: msg.address, text: REQUEST })
159    $.ui.toast(isDelivered ? `${short} is done · proof requested` : `${short} is done · proof request not delivered`)
160    return queued
161  })
162
163  on('session.send', async ($, e, next) => {
164    const r = await next(e)
165    if (e.origin.kind !== 'model' || !r.isDelivered) return r
166    const before = await read($, pending)
167    const left = before.filter(p => p.address !== e.to && p.name !== e.to)
168    if (left.length !== before.length) {
169      await update($, pending, () => left)
170    }
171    return r
172  })
173
174  // The pending count rides the hint line as a dim tail, after any tail another hook already set.
175  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
176    const line = statusLine(await read($, pending))
177    if (!line) return next(e)
178    const tail = e.props.tail ? `${e.props.tail} · ${line}` : line
179    return next({ ...e, props: { ...e.props, tail } })
180  })
181
182  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183    const list = await read($, pending)
184    if (e.props.hasSurvey || list.length === 0) return next(e)
185
186    const { Box, Button, Text } = $.ui.resolve(e)
187    const now = await $.clock.now()
188    const short = shortNames(list.map(p => p.name))
189    const isNarrow = e.props.bodyColumns < 60
190    const rows = list.slice(0, BAND_ROWS)
191    const more = list.length - rows.length
192
193    return (
194      <Box flexDirection="column">
195        {rows.map((p, i) => {
196          const name = short[i] ?? p.name
197          const files = p.files === 1 ? '1 file' : p.files ? `${p.files} files` : 'folder'
198          const detail = isNarrow ? ago(now - p.at) : `${files} · ${ago(now - p.at)}`
199          return (
200            <Box key={`row-${p.address}`} flexDirection="row" gap={1}>
201              <Text color="warning">▲</Text>
202              <Box flexShrink={1}>
203                <Text wrap="truncate-end">
204                  {name} <Text dimColor>· {detail}</Text>
205                </Text>
206              </Box>
207              <Button
208                key={`approve-${p.address}`}
209                label="Approve"
210                variant="primary"
211                onPress={() => $.prompt.fill({ text: `Approved, merge ${name}` })}
212              />
213              <Button
214                key={`changes-${p.address}`}
215                label="Ask changes"
216                dimColor
217                onPress={() => $.prompt.fill({ text: `Tell ${name}: ` })}
218              />
219              <Button
220                key={`dismiss-${p.address}`}
221                label="Dismiss"
222                dimColor
223                onPress={() => void update($, pending, l => l.filter(q => q.address !== p.address))}
224              />
225            </Box>
226          )
227        })}
228        {more > 0 ? <Text dimColor>+{more} more</Text> : null}
229      </Box>
230    )
231  })
232}
233
types/index.d.ts 20 lines
1export type ProofGatePending = {
2  name: string
3  address: string
4  /** Proof files named in the message; 0 when it only named a folder. */
5  files: number
6  /** When the proof arrived, ms since the epoch. */
7  at: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'proof-gate': {
13      /** Workers whose proof arrived and who wait for the person's approval. */
14      pending: ProofGatePending[]
15      /** Keys of messages already answered with a proof request, newest last. */
16      asked: string[]
17    }
18  }
19}
20