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

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