SLOPSHOPPER

workbench

Spend gate on paid commands, turn receipt on each turn.

spinnerguardpromptprocess
v0.1.0no licenseupdated 2026-10-09eluc1a/cc-plugins-mods-etc/workbench
A shopper browsing a rack in a slop shop

Draws

Turn line
✻ Worked… · edited 3 files · 2 uncommitted
README

claude-mods

Claude Code mods (hook plugins), published as the claude-mods marketplace.

Install

The repo is private: the machine needs git access to GitHub first (gh auth login, then gh auth setup-git, or an SSH key).

/plugin marketplace add eluc1a/cc-plugins-mods-etc
/plugin install relay@claude-mods
/plugin install agent-board@claude-mods

Update:

/plugin marketplace update claude-mods
/plugin update relay@claude-mods
/plugin update agent-board@claude-mods

Plugins

relay

Keeps a long task going across context resets. Arm it per session with /relay on.

  • Past 180K context tokens, the model gets a soft stop: finish to the next breakpoint, wait for running subagents, write a handoff.
  • Past 180K, new subagents from the main loop are denied. Running ones continue.
  • /clear waits until no background agent is running, then the session restarts from the handoff.
  • Up to 10 cycles. RELAY: DONE ends it.

Commands: /relay on, /relay off, /relay now (one handoff cycle at once).

agent-board

/board opens a pane with a live tree of the session's agents: current tool, call times, fails, tokens.

  • Lanes: an agent whose description starts with Lane N: is grouped under a colored Lane N heading. The plugin adds this rule to the system prompt.
  • Model routing: a spawn that names no model gets one from Jev (TypeSafe, through OpenRouter) when OPENROUTER_API_KEY is set in the environment or in a .env in the session's directory. Otherwise Explore agents get haiku and the rest inherit.
  • /agent-board:board-test <test> launches cheap Haiku agents to exercise the pane. With no argument it lists the tests.
  • Live file: on every event and every 10 s the mod writes a snapshot to ~/.claude/agent-board/live.json for outside viewers, such as a browser village. The top level is { at, session, main, agents }, where each agent holds its info and a stat.

Not in the marketplace

  • workbench/: spend gate on paid commands, turn receipt on each turn. Load with claude --plugin-dir workbench.
  • jev/: Jev experiments and evals. Run with jev/run.sh.

Development

From the repo root:

claude plugin test <plugin>
bunx -p typescript tsc -p <plugin>
claude plugin validate <plugin>
claude plugin validate .

A change reaches marketplace installs only with a push and a version bump in that plugin's .claude-plugin/plugin.json.

Source 4 files
hooks/register.ts 10 lines
1import type { Register } from 'claude-code'
2
3import { spendGate } from './spend-gate'
4import { turnReceipt } from './turn-receipt'
5
6export const register: Register = on => {
7  spendGate(on)
8  turnReceipt(on)
9}
10
hooks/spend-gate.tsx 89 lines
1import type { EngineInterface, On } from 'claude-code'
2
3const SPEND = /\bgh\s+(pr\s+(create|merge)|run\s+rerun|workflow\s+run)\b/
4const FILM = /\b(git\s+)?rm\b.*\/dev\/video-gen\/.*\.(mp4|mov|webm|mkv)\b/
5
6// The text of spend-guard.sh, shown when no one is attached to answer. Its retry line is
7// replaced: with no one attached, the gate denies every retry.
8const UNATTENDED = `SPEND GUARD: this command spends the user's money (CI minutes, deploy, or provider budget).
9The user has said: "do you not care about my money that you're wasting?"
10Before you run it again, do all of this in your reply to the user:
11  1. State exactly what this triggers (which workflows / deploys) and the rough cost.
12  2. Confirm every pending change is batched into this one PR. If not, batch first.
13  3. Confirm a local check cannot answer the same question.
14  4. Confirm the user asked for this spend, or ask them now.
15No one is attached to approve it now, so the spend gate denies every retry. Do not retry. Stop and report this to the user.`
16
17function triggers(command: string) {
18  if (/\bpr\s+create\b/.test(command)) return `CI on ${command.match(/--base[ =](\S+)/)?.[1] ?? 'the PR base'}`
19  if (/\bpr\s+merge\b/.test(command)) return 'merge and any deploy on the base branch'
20  if (/\brun\s+rerun\b/.test(command)) return 'CI rerun'
21  return 'workflow dispatch'
22}
23
24async function medianMinutes($: EngineInterface) {
25  try {
26    const ran = await $.process.run(['gh', 'run', 'list', '--limit', '5', '--json', 'startedAt,updatedAt'])
27    const rows = JSON.parse(ran.stdout) as { startedAt: string; updatedAt: string }[]
28    const minutes = rows.map(r => (Date.parse(r.updatedAt) - Date.parse(r.startedAt)) / 60_000).sort((a, b) => a - b)
29    if (ran.exitCode !== 0 || minutes.length === 0 || minutes.some(Number.isNaN)) return 'unknown'
30    return `about ${Math.round(minutes[Math.floor(minutes.length / 2)] ?? 0)} runner-minutes (median of last 5 runs)`
31  } catch {
32    return 'unknown'
33  }
34}
35
36async function ghCard($: EngineInterface, command: string) {
37  const { value } = await $.state.get({ plugin: 'workbench', key: 'gateChecks' })
38  const mark = (n: number | undefined) => ((n ?? 0) > 0 ? '✓ this session' : '✗ not run')
39  return [
40    `Spend check   ${command}`,
41    `Triggers      ${triggers(command)} · ${await medianMinutes($)}`,
42    `Checked       pip-audit ${mark(value?.pipAudit)} · pytest ${mark(value?.pytest)}`,
43    'Run it once?',
44  ].join('\n')
45}
46
47function higgsfieldCard(params: unknown) {
48  const p = (typeof params === 'object' && params !== null ? params : {}) as Record<string, unknown>
49  const prompt = String(p.prompt ?? '')
50  const cut = prompt.length > 80 ? `${prompt.slice(0, 79)}…` : prompt
51  return [
52    `Paid generation   ${[p.model ?? '?', p.aspect_ratio ?? '?', p.duration === undefined ? '?' : `${p.duration} s`].join(' · ')}`,
53    `Prompt            "${cut}"`,
54    'Rule  One yes for each generation (owner, 27 Sep)',
55    'Run it once?',
56  ].join('\n')
57}
58
59// Asks the owner once. Anything but "Run once" (Cancel, free text, a dismissed card) cancels.
60async function hold<E>($: EngineInterface, card: string, what: string, run: () => Promise<E>) {
61  if ((await $.session.surfaces()).length === 0) return { deny: UNATTENDED }
62  const answer = await $.ui.ask(card, { options: ['Run once', 'Cancel'], header: 'Spend check' }).catch(() => 'Cancel')
63  if (answer === 'Run once') return run()
64  return { deny: `The owner cancelled ${what} at the spend gate. Do not retry it. Ask the owner what to do next.` }
65}
66
67async function countCheck($: EngineInterface, name: 'pipAudit' | 'pytest') {
68  const { value = { pipAudit: 0, pytest: 0 } } = await $.state.get({ plugin: 'workbench', key: 'gateChecks' })
69  await $.state.set({ plugin: 'workbench', key: 'gateChecks' }, { ...value, [name]: value[name] + 1 })
70}
71
72export function spendGate(on: On) {
73  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
74    if (FILM.test(e.command)) return { deny: 'Owner rule: never delete generated films.' }
75    if (SPEND.test(e.command)) return hold($, await ghCard($, e.command), e.command, () => next(e))
76
77    const ran = await next(e)
78    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
79      if (/\bpip-audit\b/.test(e.command)) await countCheck($, 'pipAudit')
80      if (/\bpytest\b/.test(e.command)) await countCheck($, 'pytest')
81    }
82    return ran
83  })
84
85  on('tool.call', { tool: /^mcp__claude_ai_Higgsfield__.*generate/ }, async ($, e, next) =>
86    hold($, higgsfieldCard((e as { params?: unknown }).params), e.tool, () => next(e)),
87  )
88}
89
hooks/turn-receipt.tsx 84 lines
1import type { On } from 'claude-code'
2
3type Seg = [text: string, style: 'dim' | 'plain' | 'warn']
4
5// Module state: it dies on reload, and prompt.submit resets it, so a reload costs one receipt.
6let files = new Set<string>()
7let commits = 0
8let pushes = 0
9let uncommitted = 0
10let asks = false
11// The receipt of each finished turn, keyed by its duration: TurnDuration rows redraw on a scroll or
12// a resize, and durationMs is the one value the row and turn.complete share.
13const receipts = new Map<number, Seg[][]>()
14
15const EDITS = new Set(['Edit', 'Write', 'NotebookEdit'])
16const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
17
18async function receiptUncommitted($: any): Promise<number> {
19  try {
20    const { stdout } = await $.process.run(['git', 'status', '--porcelain'])
21    return stdout.split('\n').filter(Boolean).length
22  } catch {
23    return 0
24  }
25}
26
27// One group per entry, zero groups left out.
28function segments(): Seg[][] {
29  const groups: Seg[][] = []
30  if (files.size) groups.push([['edited ', 'dim'], [plural(files.size, 'file'), 'plain']])
31  if (commits) {
32    const group: Seg[] = [[plural(commits, 'commit'), 'plain']]
33    if (commits > pushes) group.push([', ', 'dim'], ['not pushed', 'warn'])
34    groups.push(group)
35  }
36  if (uncommitted) groups.push([[String(uncommitted), 'plain'], [' uncommitted', 'dim']])
37  if (asks) groups.push([['asks you ', 'warn'], ['1 question', 'plain']])
38  return groups
39}
40
41export function turnReceipt(on: On) {
42  on('prompt.submit', (_$, e, next) => {
43    files = new Set()
44    commits = pushes = uncommitted = 0
45    asks = false
46    return next(e)
47  })
48
49  on('tool.call', async (_$, e, next) => {
50    const ran = await next(e)
51    if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
52    const input = e as any
53    if (EDITS.has(e.tool)) files.add(input.file_path ?? input.notebook_path)
54    else if (e.tool === 'Bash') {
55      if (/\bgit\s+commit\b/.test(input.command)) commits += 1
56      if (/\bgit\s+push\b/.test(input.command)) pushes += 1
57    }
58    return ran
59  })
60
61  on('turn.complete', async ($, e, next) => {
62    if (e.agentId === undefined) {
63      uncommitted = await receiptUncommitted($)
64      asks = e.answer.trim().split(/\n\s*\n/).at(-1)?.trimEnd().endsWith('?') ?? false
65      receipts.set(e.durationMs, segments())
66      // The row can draw before this hook runs: draw it again with the receipt.
67      $.ui.invalidate('ui.render')
68    }
69    return next(e)
70  })
71
72  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
73    const groups = receipts.get(e.props.durationMs) ?? []
74    if (e.surface !== 'terminal' || groups.length === 0) return next(e)
75
76    const own = await next(e)
77    const { Box, Text } = $.ui.resolve(e)
78    const draw = ([text, style]: Seg) =>
79      Text({ children: [text], ...(style === 'dim' ? { dimColor: true } : style === 'warn' ? { color: 'warning' } : {}) })
80
81    return Box({ children: [own, ...groups.flatMap(group => [draw([' · ', 'dim']), ...group.map(draw)])] })
82  })
83}
84
types/index.d.ts 18 lines
1// The state contract of the workbench mod. Each feature owns the keys
2// under its own marker line, and each key starts with the feature prefix.
3
4// types: spend gate
5export type GateChecks = { pipAudit: number; pytest: number }
6
7// types: turn receipt
8
9declare module 'claude-code' {
10  interface PluginState {
11    workbench: {
12      // keys: spend gate (prefix gate)
13      gateChecks: GateChecks
14      // keys: turn receipt (prefix receipt)
15    }
16  }
17}
18