SLOPSHOPPER

workers

A pane of the sessions this one orchestrates: state, branch, commits ahead, changed files, last message, who needs you

newpanespinnercommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workers
│ ┃ Workers ✕ › fix the failing auth test and add an audit log call │ ┃ 0 workers up │ ┃ ⏺ Read(src/auth.ts) │ ┃ No workers yet ⎿ Read 6 lines │ ┃ Start some with /orchestrate ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ r: Refresh a: All sessions ⏺ 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 │ │ › /workers │ ⎿ workers: Workers pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workers
0 workers updated 0s ago No workers yet Start some with /orchestrate r: Refresh a: All sessions
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 3 files
hooks/register.tsx 321 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Contact, Worker } from '../types'
5import {
6  asksForYou,
7  bodyOf,
8  contactFor,
9  contactKey,
10  cut,
11  gitOf,
12  isWorker,
13  lastOf,
14  lookOf,
15  parseAgents,
16  senderOf,
17  sortWorkers,
18  stateOf,
19  summaryOf,
20  age,
21} from './lib'
22
23const PANE = 'workers'
24const TITLE = 'Workers'
25const EVERY_MS = 30_000
26const GONE_FOR_MS = 12 * 3600_000
27const NARROW = 50
28
29const contacts = atom({ plugin: 'workers', key: 'contacts' } as const, {})
30const rows = atom({ plugin: 'workers', key: 'rows' } as const, [])
31const others = atom({ plugin: 'workers', key: 'others' } as const, 0)
32const updatedAt = atom({ plugin: 'workers', key: 'updatedAt' } as const, 0)
33const showAll = atom({ plugin: 'workers', key: 'showAll' } as const, false)
34const error = atom({ plugin: 'workers', key: 'error' } as const, null)
35const isOpen = atom({ plugin: 'workers', key: 'isOpen' } as const, false)
36const needs = atom({ plugin: 'workers', key: 'needs' } as const, 0)
37
38async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
39  try {
40    const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5000 })
41    return r.exitCode === 0 ? r.stdout.trim() : null
42  } catch {
43    return null
44  }
45}
46
47async function gitInfo($: EngineInterface, cwd: string) {
48  const branch = await git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
49  if (branch === null) return { branch: null, ahead: null, dirty: null }
50  const [ahead, status] = await Promise.all([
51    git($, cwd, ['rev-list', '--count', 'main..HEAD']),
52    git($, cwd, ['status', '--porcelain']),
53  ])
54  return {
55    branch,
56    ahead: ahead === null ? null : Number(ahead),
57    dirty: status === null ? null : status.split('\n').filter(Boolean).length,
58  }
59}
60
61let tick: { cancel: () => void } | null = null
62let isRefreshing = false
63
64async function refresh($: EngineInterface): Promise<number> {
65  if (isRefreshing) return (await read($, rows)).length
66  isRefreshing = true
67  try {
68    const self = await $.session.id()
69    const repo = await $.session.repo()
70    let agentsOut: string
71    try {
72      const r = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 10_000 })
73      if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`)
74      agentsOut = r.stdout
75    } catch (err) {
76      await update($, error, () => `Could not list sessions: ${cut(String(err), 100)}`)
77      await update($, updatedAt, () => Date.now())
78      return (await read($, rows)).length
79    }
80    const known = await read($, contacts)
81    const all = await read($, showAll)
82    const now = Date.now()
83    const agents = parseAgents(agentsOut).filter(a => a.sessionId !== self)
84    const picked = agents.filter(a => all || isWorker(a, known, repo?.root ?? null)).slice(0, 24)
85    const live: Worker[] = await Promise.all(
86      picked.map(async a => ({
87        name: a.name,
88        pid: a.pid,
89        sessionId: a.sessionId,
90        cwd: a.cwd,
91        state: stateOf(a.status),
92        waitingFor: a.waitingFor ?? null,
93        ...(await gitInfo($, a.cwd)),
94      })),
95    )
96    // Workers this session talked to that are no longer running.
97    const names = new Set(agents.map(a => a.name))
98    const gone: Worker[] = Object.entries(known)
99      .filter(([key, c]) => !key.startsWith('pid:') && !names.has(key) && now - c.at < GONE_FOR_MS)
100      .map(([name]) => ({
101        name,
102        pid: 0,
103        sessionId: '',
104        cwd: '',
105        state: 'gone',
106        waitingFor: null,
107        branch: null,
108        ahead: null,
109        dirty: null,
110      }))
111    const list = [...live, ...gone]
112    await update($, rows, () => list)
113    await update($, others, () => agents.length - live.length)
114    await update($, error, () => null)
115    await update($, updatedAt, () => now)
116    // Only real workers count toward the hint tail, also while the pane shows every session.
117    const workerIds = new Set(agents.filter(a => isWorker(a, known, repo?.root ?? null)).map(a => a.sessionId))
118    const isReal = (w: Worker) => w.state === 'gone' || workerIds.has(w.sessionId)
119    const waiting = list.filter(w => isReal(w) && lookOf(w, contactFor(known, w)).rank === 0).length
120    await update($, needs, () => waiting)
121    return list.length
122  } finally {
123    isRefreshing = false
124  }
125}
126
127async function openPane($: EngineInterface): Promise<void> {
128  await $.ui.open({ id: PANE, title: TITLE })
129  await update($, isOpen, () => true)
130  tick?.cancel()
131  tick = $.clock.every(EVERY_MS, () => void refresh($))
132}
133
134async function touch($: EngineInterface, key: string, contact: Contact): Promise<void> {
135  await update($, contacts, all => ({ ...all, [key]: contact }))
136  if (await read($, isOpen)) void refresh($)
137}
138
139export const register: Register = on => {
140  on('session.start', async ($, e, next) => {
141    const started = await next(e)
142    await $.command.register({
143      name: 'workers',
144      description: 'Toggle the workers pane (/workers all: every session, /workers refresh)',
145    })
146    if (await read($, isOpen)) {
147      // A reload keeps the pane but not the timer.
148      await openPane($)
149      void refresh($)
150    } else if ((await refresh($)) > 0) {
151      await openPane($)
152    }
153    return started
154  })
155
156  on('command.run', { command: 'workers' }, async ($, e) => {
157    const arg = e.args.trim()
158    if (arg === 'all') {
159      const all = !(await read($, showAll))
160      await update($, showAll, () => all)
161      await openPane($)
162      await refresh($)
163      return { text: all ? 'Workers pane shows every session' : 'Workers pane shows workers only' }
164    }
165    if (arg === 'refresh') {
166      await openPane($)
167      await refresh($)
168      return { text: 'Workers pane refreshed' }
169    }
170    if (await read($, isOpen)) {
171      await $.ui.close({ id: PANE })
172      return { text: 'Workers pane closed' }
173    }
174    await openPane($)
175    await refresh($)
176    return { text: 'Workers pane opened' }
177  })
178
179  on('ui.close', { id: PANE }, async ($, e, next) => {
180    tick?.cancel()
181    tick = null
182    await update($, isOpen, () => false)
183    return next(e)
184  })
185
186  on('session.send', async ($, e, next) => {
187    const sent = await next(e)
188    if (e.agentId === undefined && sent.isDelivered) {
189      await touch($, contactKey(e.to), {
190        text: bodyOf(e.text).slice(0, 200),
191        at: Date.now(),
192        dir: 'out',
193        needsYou: false,
194      })
195    }
196    return sent
197  })
198
199  on('session.receive', async ($, e, next) => {
200    const received = await next(e)
201    const isPeer = e.origin.kind === 'peer' || e.origin.kind === 'peer-send-message'
202    const from = isPeer && e.agentId === undefined ? senderOf(e.text) : null
203    if (from !== null) {
204      const body = bodyOf(e.text)
205      await touch($, from, { text: body.slice(0, 200), at: Date.now(), dir: 'in', needsYou: asksForYou(body) })
206    }
207    return received
208  })
209
210  // Workers waiting on the person ride the hint line as a dim tail, after any tail another hook already set.
211  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
212    const n = await read($, needs)
213    if (n <= 0) return next(e)
214    const line = `${n} worker${n === 1 ? '' : 's'} waiting`
215    const tail = e.props.tail ? `${e.props.tail} · ${line}` : line
216    return next({ ...e, props: { ...e.props, tail } })
217  })
218
219  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220    const { Box, Text, Button } = $.ui.resolve(e)
221    const known = await read($, contacts)
222    const list = sortWorkers(await read($, rows), known)
223    const at = await read($, updatedAt)
224    const failed = await read($, error)
225    const rest = await read($, others)
226    const all = await read($, showAll)
227    const now = Date.now()
228    const width = Math.max(20, e.props.bodyColumns)
229    const isNarrow = width < NARROW
230
231    const footer = (
232      <Box flexDirection="row" gap={2} marginTop={1}>
233        <Button key="refresh" plain hotkey="r" label="Refresh" onPress={() => void refresh($)} />
234        <Button
235          key="all"
236          plain
237          hotkey="a"
238          dimColor
239          label={all ? 'Workers only' : 'All sessions'}
240          onPress={async () => {
241            await update($, showAll, v => !v)
242            await refresh($)
243          }}
244        />
245      </Box>
246    )
247
248    if (at === 0) {
249      return (
250        <Box flexDirection="column">
251          <Text dimColor>Reading sessions…</Text>
252        </Box>
253      )
254    }
255
256    const summary = summaryOf(list, known, all ? 'session' : 'worker') + (rest > 0 && !isNarrow ? ` · ${rest} other` : '')
257    const updated = `updated ${age(now - at)} ago`
258
259    return (
260      <Box flexDirection="column" width={width}>
261        {isNarrow ? (
262          <Box flexDirection="column">
263            <Text bold wrap="truncate-end">{summary}</Text>
264            <Text dimColor>{updated}</Text>
265          </Box>
266        ) : (
267          <Box flexDirection="row" justifyContent="space-between" width={width}>
268            <Text bold wrap="truncate-end">{cut(summary, width - updated.length - 2)}</Text>
269            <Text dimColor>{updated}</Text>
270          </Box>
271        )}
272        {failed !== null && (
273          <Text color="error" wrap="truncate-end">
274            ✕ {cut(failed, width - 2)}
275          </Text>
276        )}
277        {list.length === 0 && (
278          <Box flexDirection="column" marginTop={1}>
279            <Text dimColor>No workers yet</Text>
280            <Text dimColor>Start some with /orchestrate</Text>
281          </Box>
282        )}
283        {list.map(w => {
284          const c = contactFor(known, w)
285          const look = lookOf(w, c)
286          const branch = gitOf(w)
287          const right = isNarrow ? '' : branch
288          const nameRoom = width - 2 - (right ? right.length + 2 : 0)
289          const second = isNarrow && branch ? `${branch} · ` : ''
290          const last = lastOf(w, c, now, width - 2 - second.length)
291          return (
292            <Box key={`w-${w.name}`} flexDirection="column" marginTop={1}>
293              <Box flexDirection="row" justifyContent="space-between" width={width}>
294                <Box flexDirection="row">
295                  <Text color={look.color} dimColor={look.dim}>
296                    {look.glyph}{' '}
297                  </Text>
298                  <Button
299                    key={`tell-${w.name}`}
300                    plain
301                    label={cut(w.name, Math.max(4, nameRoom))}
302                    dimColor={look.rank === 3}
303                    onPress={() => void $.prompt.fill({ text: `Tell ${w.name}: ` })}
304                  />
305                </Box>
306                {right !== '' && <Text dimColor>{right}</Text>}
307              </Box>
308              <Text dimColor wrap="truncate-end">
309                {'  '}
310                {second}
311                {last}
312              </Text>
313            </Box>
314          )
315        })}
316        {footer}
317      </Box>
318    )
319  })
320}
321
hooks/lib.ts 142 lines
1import type { Contact, Worker } from '../types'
2
3/** One entry of `claude agents --json`. */
4export type AgentEntry = {
5  pid: number
6  cwd: string
7  kind?: string
8  sessionId: string
9  name: string
10  status: string
11  waitingFor?: string
12}
13
14export const parseAgents = (stdout: string): AgentEntry[] => {
15  try {
16    const list: unknown = JSON.parse(stdout)
17    if (!Array.isArray(list)) return []
18    return list.filter(
19      (a): a is AgentEntry =>
20        typeof a === 'object' && a !== null && typeof a.sessionId === 'string' && typeof a.name === 'string',
21    )
22  } catch {
23    return []
24  }
25}
26
27/** `uds:/tmp/cc-socks/16932.sock` -> `pid:16932`; a plain name stays itself. */
28export const contactKey = (address: string): string => {
29  const sock = /cc-socks\/(\d+)\.sock/.exec(address)
30  return sock ? `pid:${sock[1]}` : address.trim()
31}
32
33/**
34 * Who sent a cross-session message, read off its envelope:
35 * `<cross-session-message from="uds:..." from-name="brf-app-reading" ...>`.
36 */
37export const senderOf = (text: string): string | null => {
38  const name = /from-name="([^"]+)"/.exec(text)
39  if (name?.[1]) return name[1]
40  const from = /from="([^"]+)"/.exec(text)?.[1]
41  return from && from !== '...' ? contactKey(from) : null
42}
43
44/** The message body without the envelope tags, on one line. */
45export const bodyOf = (text: string): string =>
46  text
47    .replace(/<\/?cross-session-message[^>]*>/g, ' ')
48    .replace(/\s+/g, ' ')
49    .trim()
50
51export const asksForYou = (body: string): boolean =>
52  /\b(approve|approval|decision|decide|blocked|waiting for)\b/i.test(body) || /\?\s*$/.test(body)
53
54export const contactFor = (
55  contacts: Record<string, Contact>,
56  agent: { name: string; pid: number },
57): Contact | undefined => {
58  const byName = contacts[agent.name]
59  const byPid = contacts[`pid:${agent.pid}`]
60  if (byName && byPid) return byName.at >= byPid.at ? byName : byPid
61  return byName ?? byPid
62}
63
64export const stateOf = (status: string): string =>
65  status === 'busy' ? 'running' : status === 'waiting' ? 'blocked' : status
66
67/**
68 * A worker: a session this one has messaged or heard from, or one running
69 * in a worktree of this session's repository.
70 */
71export const isWorker = (
72  agent: AgentEntry,
73  contacts: Record<string, Contact>,
74  repoRoot: string | null,
75): boolean =>
76  contactFor(contacts, agent) !== undefined ||
77  (repoRoot !== null && agent.cwd.startsWith(`${repoRoot}/.claude/worktrees/`))
78
79export const age = (ms: number): string => {
80  const s = Math.max(0, Math.round(ms / 1000))
81  if (s < 60) return `${s}s`
82  if (s < 3600) return `${Math.floor(s / 60)}m`
83  if (s < 86400) return `${Math.floor(s / 3600)}h`
84  return `${Math.floor(s / 86400)}d`
85}
86
87/** Cuts to `width` cells, ending in … when cut. */
88export const cut = (text: string, width: number): string =>
89  width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1)).trimEnd()}…`
90
91/** The one glyph and theme color a worker shows, by what it needs. */
92export type Look = { glyph: string; color?: string; dim: boolean; rank: number; label: string }
93
94export const lookOf = (w: Worker, c: Contact | undefined): Look => {
95  if (w.state === 'gone') return { glyph: '✕', color: 'error', dim: false, rank: 3, label: 'gone' }
96  if (w.state === 'blocked' && w.waitingFor && /limit/i.test(w.waitingFor))
97    return { glyph: '⏸', dim: false, rank: 1, label: 'paused by limit' }
98  if (c?.needsYou || w.state === 'blocked')
99    return { glyph: '▲', color: 'warning', dim: false, rank: 0, label: 'needs you' }
100  if (w.state === 'running') return { glyph: '●', color: 'success', dim: false, rank: 1, label: 'running' }
101  return { glyph: '○', dim: true, rank: 2, label: w.state }
102}
103
104export const sortWorkers = (list: Worker[], contacts: Record<string, Contact>): Worker[] =>
105  [...list].sort((a, b) => {
106    const ra = lookOf(a, contactFor(contacts, a)).rank
107    const rb = lookOf(b, contactFor(contacts, b)).rank
108    if (ra !== rb) return ra - rb
109    const ta = contactFor(contacts, a)?.at ?? 0
110    const tb = contactFor(contacts, b)?.at ?? 0
111    return tb - ta || a.name.localeCompare(b.name)
112  })
113
114/** "4 workers · 2 running · 1 needs you", zero parts left out. */
115export const summaryOf = (list: Worker[], contacts: Record<string, Contact>, noun = 'worker'): string => {
116  const looks = list.map(w => lookOf(w, contactFor(contacts, w)))
117  const count = (rank: number) => looks.filter(l => l.rank === rank && l.label !== 'paused by limit').length
118  const parts = [`${list.length} ${noun}${list.length === 1 ? '' : 's'}`]
119  const running = looks.filter(l => l.label === 'running').length
120  const needs = count(0)
121  const paused = looks.filter(l => l.label === 'paused by limit').length
122  const gone = count(3)
123  if (running) parts.push(`${running} running`)
124  if (needs) parts.push(`${needs} need${needs === 1 ? 's' : ''} you`)
125  if (paused) parts.push(`${paused} paused`)
126  if (gone) parts.push(`${gone} gone`)
127  return parts.join(' · ')
128}
129
130/** "worktree-ui +3 ~2": ahead and dirty counts, zeros left out. */
131export const gitOf = (w: Worker): string =>
132  w.branch === null ? '' : [w.branch, w.ahead ? `+${w.ahead}` : '', w.dirty ? `~${w.dirty}` : ''].filter(Boolean).join(' ')
133
134/** The second line: the last message with its age, or what the session waits for. */
135export const lastOf = (w: Worker, c: Contact | undefined, now: number, width: number): string => {
136  const waiting = w.state === 'blocked' && w.waitingFor ? `waiting: ${w.waitingFor}` : ''
137  if (!c) return cut(waiting || 'No messages yet', width)
138  const when = ` · ${age(now - c.at)}`
139  const body = `${c.dir === 'in' ? '' : 'you: '}${c.text}`
140  return cut(body, width - when.length) + when
141}
142
types/index.d.ts 44 lines
1export type Contact = {
2  /** Last message text, first 80 characters, one line. */
3  text: string
4  /** When it was sent or received, ms since the epoch. */
5  at: number
6  /** `in` received from the worker, `out` sent to it. */
7  dir: 'in' | 'out'
8  /** The worker asked for a decision and this session has not answered yet. */
9  needsYou: boolean
10}
11
12export type Worker = {
13  name: string
14  pid: number
15  sessionId: string
16  cwd: string
17  /** running, idle or blocked, from `claude agents`; gone once it left that list. */
18  state: string
19  /** What a blocked session waits for, as `claude agents` says it. */
20  waitingFor: string | null
21  branch: string | null
22  ahead: number | null
23  dirty: number | null
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    workers: {
29      /** Last message per worker, keyed by name, or `pid:<n>` when only the socket is known. */
30      contacts: Record<string, Contact>
31      rows: Worker[]
32      /** Other sessions running that are not counted as workers. */
33      others: number
34      updatedAt: number
35      showAll: boolean
36      error: string | null
37      /** Whether this plugin's pane is open, kept across reloads. */
38      isOpen: boolean
39      /** Workers waiting on the person; the hint line's tail. */
40      needs: number
41    }
42  }
43}
44