SLOPSHOPPER

agents-side

Shows the session's agents in a side pane: the main loop and every subagent it spawns, with tools, status, and answers.

newpaneguardcommandstatustimer
v0.1.0no licenseupdated 2026-10-06yogeshvar/mod-claude/plugins/agents-side
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-side
│ ┃ agents-side ✕ › fix the failing auth test and add an audit log call │ ┃ Agents │ ┃ waiting for subagents ⏺ Read(src/auth.ts) │ ┃ 1: ›· main 49% · 9 tools · 42.0s ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ┌────────────────────────────────────────┐ ⎿ Added 2 lines, removed 1 line │ ┃ │ · main │ ⏺ Bash(bun test) │ ┃ │ claude-opus-5-5 · idle │ ⎿ 3 pass, 1 fail │ ┃ │ ██████████████░░░░░░░░░░░░░░ 49% │ │ ┃ │ 97.4k/200k │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ⚙ 9 tools · Write, Write, Bash, Bash, │ │ ┃ │ Bash, Bash · i… │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ↑ out Done. I made `refresh` reject │ │ ┃ │ expired claims, a… │ › /agents-side │ ┃ └────────────────────────────────────────┘ ⎿ agents-side: Closed the agents pane. │ ┃ │ ┃ c: clear done x: close │ ┃ click the pane or ctrl+x tab, then 1-9 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ agents-side: agents: 0 running · 0 done · ctx 49%

Draws

Pane · agents-side
Agents waiting for subagents 1: ›· main 49% · 9 tools · 42.0s ┌────────────────────────────────────────────────────────┐ │ · main │ │ claude-opus-5-5 · idle │ │ ██████████████░░░░░░░░░░░░░░ 49% 97.4k/200k │ │ ⚙ 9 tools · Write, Write, Bash, Bash, Bash, Bash · i… │ │ ↑ out Done. I made `refresh` reject expired claims, a… │ └────────────────────────────────────────────────────────┘ c: clear done x: close click the pane or ctrl+x tab, then 1-9
README

Claude Code mods

A small CLI cockpit for teams that already have agents, hooks, and instructions, and that still use Claude Code in the terminal (including Bedrock). Mods draw live UI and can hold a tool call. They do not replace those files.

  1. context-board — compact context card above the prompt, plus a one-line kit strip of what this session actually loaded
  2. agents-side — a pane of the main loop and every subagent it spawns
  3. session-map — files Claude read, edited, or wrote this session, grouped by monorepo app
  4. blast-radius — holds risky Bash until you Proceed or Cancel

Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Install

claude plugin marketplace add yogeshvar/mod-claude
claude plugin install agents-side@mod-claude
claude plugin install context-board@mod-claude
claude plugin install session-map@mod-claude
claude plugin install blast-radius@mod-claude

Or clone and load for one session:

git clone https://github.com/yogeshvar/mod-claude.git
cd mod-claude
./start-claude

Then:

  1. Stretch the terminal to at least 110 columns (144 if you want unasked panes to auto-dock). Run /tui fullscreen so panes sit beside the transcript.
  2. The context card sits above the prompt: used vs window, compact threshold, a stacked color bar, a small legend, and a kit line (skills 4 · agents 2 · CLAUDE.md · plugins …).
  3. Ask Claude to spawn a couple of subagents. If the agents pane did not auto-open, run /agents-side.
  4. Run /session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.
  5. A pnpm migrate, git reset --hard, git push --force, DROP TABLE, rm -rf, or rm .env opens Blast Radius. 1 proceeds, 2 cancels. This is a safety net, not a permission system — keep hard denies in managed settings.

Commands

PluginCommandWhat it does
agents-side/agents-sideOpen or close the pane
agents-side/agents-side clearDrop finished agents
agents-side/agents-side closeClose the pane
context-board/context-boardOpen or close the pane (the bar stays)
context-board/context-board refreshRecount every category with the token-count API
context-board/context-board paneForce-open the pane
context-board/context-board closeClose the pane
session-map/session-mapOpen or close the file pane
session-map/session-map clearDrop the file list
session-map/session-map closeClose the pane
blast-radius/blast-radiusRe-open the hold pane if a command is waiting
blast-radius/blast-radius closeCancel the held command

On the agents pane, keys 1–9 select a row (focus the pane first: click it, or ctrl+x tab). c clears finished agents, x closes it.

On the context pane: r refresh, x close.

On the session map: c clear, x close.

On Blast Radius: 1 Proceed, 2 Cancel.

openOnStart (agents pane; session-map, off by default) and openPaneOnStart (context pane, off by default) are plugin options under /config.

Bedrock and team machines

Claude Code on Amazon Bedrock / an API key does not load the built-in sec-default guard unless you push managed settings. Without that, a user-installed mod can override deny rules.

For the team:

  1. Allow this marketplace (and block unknown ones) in managed settings.
  2. Put sec-default in prependPlugins.
  3. Keep production denies in permission rules. Blast Radius only holds commands whose text looks risky ($(…) and scripts that call rm can still get past it).
  4. On Bedrock, $.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.

What each one shows

context-board calls $.session.usage({ breakdown: "summary" }) after each main-loop turn (local estimates, no extra API). /context-board refresh uses breakdown: "full". The kit strip reads enabled plugins from settings, CLAUDE.md / AGENTS.md from the instruction walk, skill and agent counts from usage, and the last Skill tool name.

agents-side watches agent.spawn, tool.call, turn.start, turn.complete, and session.measure. It never blocks those events.

session-map observes Read, Edit, Write, and Glob after they run. It never blocks.

blast-radius classifies Bash, dry-runs git status / git clean -n when it can, and holds the call until Proceed or Cancel.

Check the plugins

claude plugin validate .
claude plugin validate ./plugins/agents-side
claude plugin validate ./plugins/context-board
claude plugin validate ./plugins/session-map
claude plugin validate ./plugins/blast-radius
claude plugin test ./plugins/agents-side
claude plugin test ./plugins/context-board
claude plugin test ./plugins/session-map
claude plugin test ./plugins/blast-radius
Source 4 files
hooks/register.ts 235 lines
1import { drawPane } from './draw.ts'
2import { clip } from './format.ts'
3import { MAIN, PANE, countsOf, emptyMain, treeOf, type AgentRow } from './model.ts'
4
5const agents: AgentRow[] = [emptyMain()]
6let selectedId = MAIN
7
8function rowOf(id: string): AgentRow {
9  return agents.find(row => row.id === id) ?? emptyMain()
10}
11
12function put(next: AgentRow) {
13  const i = agents.findIndex(row => row.id === next.id)
14  if (i === -1) agents.push(next)
15  else agents[i] = next
16}
17
18function patch(id: string, partial: Partial<AgentRow>) {
19  put({ ...rowOf(id), ...partial, id })
20}
21
22function statusLine() {
23  const { running, done } = countsOf(agents)
24  const main = rowOf(MAIN)
25  const ctx = main.percent != null ? ` · ctx ${main.percent}%` : ''
26  return `agents: ${running} running · ${done} done${ctx}`
27}
28
29function pin($: { ui: { status: (text: string | undefined) => void; invalidate: (event: 'ui.render') => void } }) {
30  $.ui.status(statusLine())
31  $.ui.invalidate('ui.render')
32}
33
34async function syncListed($: { agent: { list: () => Promise<Array<{ id: string; status: string; description: string; type: string; parentId?: string; name?: string }>> } }) {
35  const listed = await $.agent.list()
36  for (const info of listed) {
37    const prev = agents.find(row => row.id === info.id)
38    patch(info.id, {
39      type: info.type || prev?.type || 'agent',
40      description: info.description || prev?.description || info.type,
41      status: info.status || prev?.status || 'running',
42      parentId: info.parentId ?? prev?.parentId,
43      name: info.name ?? prev?.name,
44    })
45  }
46}
47
48function paneArgs(columns: number) {
49  return {
50    id: PANE,
51    title: 'Agents',
52    columns,
53    rows: 22,
54  }
55}
56
57async function isOpen($: { ui: { panes: () => Promise<Array<{ id: string; isPlaced: boolean }>> } }) {
58  const panes = await $.ui.panes()
59  return panes.some(pane => pane.id === PANE && pane.isPlaced)
60}
61
62export function register(
63  on: (
64    event: string,
65    matcher?: unknown,
66    hook?: (...args: never[]) => unknown,
67  ) => void,
68  options: Readonly<Record<string, string | number | boolean | readonly string[]>>,
69) {
70  const columns = typeof options.columns === 'number' ? options.columns : 48
71  const openOnStart = options.openOnStart !== false
72
73  on('session.start', async ($, e, next) => {
74    const result = await next(e)
75    await $.command.register({
76      name: 'agents-side',
77      description: 'Toggle the agents side pane',
78      argumentHint: '[clear|close]',
79      immediate: true,
80    })
81    if (e.isInteractive && openOnStart) {
82      await $.ui.open(paneArgs(columns))
83    }
84    await syncListed($)
85    pin($)
86    $.clock.every(2000, () => {
87      void syncListed($).then(() => pin($))
88    })
89    return result
90  })
91
92  on('turn.start', async ($, e, next) => {
93    const result = await next(e)
94    patch(MAIN, {
95      status: 'running',
96      prompt: e.text,
97      description: e.text ? clip(e.text, 40) : 'main loop',
98    })
99    pin($)
100    return result
101  })
102
103  on('session.measure', async ($, e, next) => {
104    const result = await next(e)
105    patch(MAIN, {
106      percent: e.context.percent,
107      window: e.context.window,
108      inputTokens: e.context.tokens,
109    })
110    pin($)
111    return result
112  })
113
114  on('agent.spawn', async ($, e, next) => {
115    const result = await next(e)
116    if ('deny' in result && result.deny) return result
117    const id = result.agentId ?? e.tool_use_id
118    put({
119      id,
120      parentId: e.parentAgentId ?? MAIN,
121      type: e.subagentType,
122      description: e.description || e.subagentType,
123      name: e.name,
124      status: 'running',
125      prompt: e.prompt,
126      model: result.model ?? e.model ?? e.parentModel,
127      fork: e.fork,
128      background: e.background,
129      toolCount: 0,
130      lastTools: [],
131    })
132    selectedId = id
133    pin($)
134    return result
135  }).catch(($, e, next) => next(e))
136
137  on('tool.call', async ($, e, next) => {
138    const result = await next(e)
139    const id = e.agentId ?? MAIN
140    if (!agents.some(row => row.id === id)) {
141      put({
142        ...emptyMain(),
143        id,
144        type: 'loop',
145        description: `loop ${id}`,
146        status: 'running',
147        parentId: MAIN,
148      })
149    }
150    const prev = rowOf(id)
151    patch(id, {
152      status: prev.status === 'idle' ? 'running' : prev.status,
153      toolCount: prev.toolCount + 1,
154      lastTools: [...prev.lastTools, String(e.tool)].slice(-6),
155    })
156    pin($)
157    return result
158  }).catch(($, e, next) => next(e))
159
160  on('turn.complete', async ($, e, next) => {
161    const result = await next(e)
162    const id = e.agentId ?? MAIN
163    let status = 'completed'
164    if (e.reason === 'aborted') status = 'aborted'
165    else if (e.reason === 'error' || e.reason === 'refusal') status = 'failed'
166    const prev = rowOf(id)
167    patch(id, {
168      status: id === MAIN && status === 'completed' ? 'idle' : status,
169      durationMs: e.durationMs,
170      answer: e.answer,
171      inputTokens: e.usage?.input_tokens ?? prev.inputTokens,
172      outputTokens: e.usage?.output_tokens,
173      model: e.usage?.model ?? prev.model,
174    })
175    pin($)
176    return result
177  })
178
179  on('command.run', { command: 'agents-side' }, async ($, e) => {
180    const arg = e.args.trim()
181    if (arg === 'close') {
182      await $.ui.close({ id: PANE })
183      return { text: 'Closed the agents pane.' }
184    }
185    if (arg === 'clear') {
186      const kept = agents.filter(row => row.id === MAIN || row.status === 'running')
187      agents.splice(0, agents.length, ...kept)
188      if (!agents.some(row => row.id === selectedId)) selectedId = MAIN
189      pin($)
190      return { text: 'Cleared finished agents.' }
191    }
192    if (await isOpen($)) {
193      await $.ui.close({ id: PANE })
194      return { text: 'Closed the agents pane.' }
195    }
196    await $.ui.open(paneArgs(columns))
197    return {
198      text: 'Opened the agents pane. Dock it beside the transcript with /tui fullscreen in a terminal at least 110 columns wide.',
199    }
200  })
201
202  on('ui.render', { component: 'Pane' }, ($, e, next) => {
203    if (e.requestId !== PANE) return next(e)
204    const { Box, Text, Button } = $.ui.resolve(e)
205    const width = e.props.bodyColumns
206    return drawPane(Box, Text, Button, {
207      nodes: treeOf(agents),
208      selectedId,
209      bodyColumns: width,
210      isFocused: e.props.isFocused,
211    })
212  })
213
214  on('ui.press', async ($, e, next) => {
215    if (e.requestId !== PANE) return next(e)
216    if (e.element === 'clear') {
217      const kept = agents.filter(row => row.id === MAIN || row.status === 'running')
218      agents.splice(0, agents.length, ...kept)
219      if (!agents.some(row => row.id === selectedId)) selectedId = MAIN
220      pin($)
221      return {}
222    }
223    if (e.element === 'close') {
224      await $.ui.close({ id: PANE })
225      return {}
226    }
227    if (e.element.startsWith('pick-')) {
228      selectedId = e.element.slice('pick-'.length)
229      pin($)
230      return {}
231    }
232    return next(e)
233  })
234}
235
hooks/draw.ts 150 lines
1import { bar, clip, duration, fillTone, mark, short, tone } from './format.ts'
2import { MAIN, type AgentRow, type TreeNode } from './model.ts'
3
4export type Node = unknown
5export type El = (props: Record<string, unknown>) => Node
6
7export type Snapshot = {
8  nodes: TreeNode[]
9  selectedId: string
10  bodyColumns: number
11  isFocused: boolean
12}
13
14function right(row: AgentRow): string {
15  const bits: string[] = []
16  if (row.percent != null) bits.push(`${row.percent}%`)
17  else if (row.inputTokens != null) bits.push(short(row.inputTokens))
18  if (row.toolCount > 0) bits.push(`${row.toolCount} tools`)
19  if (row.durationMs != null && row.status !== 'running') bits.push(duration(row.durationMs))
20  return bits.join(' · ')
21}
22
23function labelOf(row: AgentRow): string {
24  if (row.id === MAIN) return 'main'
25  const title = row.description || row.name || row.type
26  return row.type === title ? title : `${row.type}  ${title}`
27}
28
29export function drawPane(Box: El, Text: El, Button: El, snap: Snapshot): Node {
30  const width = Math.max(24, snap.bodyColumns)
31  const nameWidth = Math.max(12, width - 18)
32  const selected = snap.nodes.find(node => node.row.id === snap.selectedId)?.row
33
34  const rows: Node[] = [
35    Text({ bold: true, children: 'Agents' }),
36    Text({
37      dimColor: true,
38      children: snap.nodes.length <= 1 ? 'waiting for subagents' : `${snap.nodes.length - 1} spawned this session`,
39    }),
40  ]
41
42  snap.nodes.forEach((node, index) => {
43    const hotkey = index < 9 ? String(index + 1) : undefined
44    const selectedMark = node.row.id === snap.selectedId ? '›' : ' '
45    const line = `${selectedMark}${node.prefix}${mark(node.row.status)} ${clip(labelOf(node.row), nameWidth - node.prefix.length - 3)}`
46    rows.push(
47      Box({
48        key: `row-${node.row.id}`,
49        flexDirection: 'row',
50        justifyContent: 'space-between',
51        children: [
52          Button({
53            key: `pick-${node.row.id}`,
54            label: line,
55            plain: true,
56            hotkey,
57            dimColor: node.row.id !== snap.selectedId,
58            onPress: () => undefined,
59          }),
60          Text({
61            dimColor: true,
62            children: right(node.row),
63          }),
64        ],
65      }),
66    )
67  })
68
69  if (selected) {
70    rows.push(Text({ children: ' ' }))
71    rows.push(drawDetail(Box, Text, selected, width))
72  }
73
74  rows.push(Text({ children: ' ' }))
75  rows.push(
76    Box({
77      flexDirection: 'row',
78      columnGap: 2,
79      children: [
80        Button({ key: 'clear', label: 'clear done', hotkey: 'c', plain: true, onPress: () => undefined }),
81        Button({ key: 'close', label: 'close', hotkey: 'x', plain: true, onPress: () => undefined }),
82      ],
83    }),
84  )
85
86  if (!snap.isFocused) {
87    rows.push(Text({ dimColor: true, children: 'click the pane or ctrl+x tab, then 1-9' }))
88  }
89
90  return Box({ flexDirection: 'column', paddingX: 1, paddingY: 0, children: rows })
91}
92
93function drawDetail(Box: El, Text: El, row: AgentRow, width: number): Node {
94  const inner = Math.max(16, width - 2)
95  const kids: Node[] = [
96    Text({
97      color: tone(row.status),
98      bold: true,
99      children: `${mark(row.status)} ${clip(labelOf(row), inner - 2)}`,
100    }),
101    Text({
102      dimColor: true,
103      children: [
104        row.model ?? (row.id === MAIN ? 'session model' : 'model pending'),
105        row.status,
106        row.fork ? 'fork' : undefined,
107        row.background ? 'background' : undefined,
108      ]
109        .filter(Boolean)
110        .join(' · '),
111    }),
112  ]
113
114  if (row.id === MAIN && row.percent != null && row.window != null) {
115    kids.push(
116      Text({
117        color: fillTone(row.percent),
118        children: `${bar(row.percent, Math.min(28, inner))} ${row.percent}%  ${short(row.inputTokens ?? 0)}/${short(row.window)}`,
119      }),
120    )
121  }
122
123  if (row.prompt) {
124    kids.push(Text({ dimColor: true, children: `↓ in  ${clip(row.prompt, inner - 6)}` }))
125  }
126
127  const work: string[] = []
128  if (row.toolCount > 0) work.push(`${row.toolCount} tools`)
129  if (row.lastTools.length > 0) work.push(row.lastTools.join(', '))
130  if (row.inputTokens != null) work.push(`in ${short(row.inputTokens)}`)
131  if (row.outputTokens != null) work.push(`out ${short(row.outputTokens)}`)
132  if (work.length > 0) {
133    kids.push(Text({ children: `⚙  ${clip(work.join(' · '), inner - 3)}` }))
134  }
135
136  if (row.answer) {
137    kids.push(Text({ dimColor: true, children: `↑ out ${clip(row.answer, inner - 6)}` }))
138  } else if (row.status === 'running') {
139    kids.push(Text({ color: 'cyan', children: '↑ out  still running' }))
140  }
141
142  return Box({
143    flexDirection: 'column',
144    borderStyle: 'single',
145    borderColor: 'gray',
146    paddingX: 1,
147    children: kids,
148  })
149}
150
hooks/format.ts 55 lines
1export function short(n: number): string {
2  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
3  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
4  return String(Math.round(n))
5}
6
7export function clip(text: string, max: number): string {
8  const t = text.replace(/\s+/g, ' ').trim()
9  if (t.length <= max) return t
10  if (max <= 1) return '…'
11  return `${t.slice(0, max - 1)}…`
12}
13
14export function pad(text: string, width: number): string {
15  const t = clip(text, width)
16  return t.length >= width ? t : t + ' '.repeat(width - t.length)
17}
18
19export function bar(percent: number, width: number): string {
20  const w = Math.max(4, width)
21  const p = Math.max(0, Math.min(100, percent))
22  const filled = Math.round((p / 100) * w)
23  return `${'█'.repeat(filled)}${'░'.repeat(Math.max(0, w - filled))}`
24}
25
26export function mark(status: string): string {
27  if (status === 'running') return '◐'
28  if (status === 'completed') return '●'
29  if (status === 'failed' || status === 'error' || status === 'refusal') return '✗'
30  if (status === 'killed' || status === 'aborted') return '○'
31  return '·'
32}
33
34export function tone(status: string): string {
35  if (status === 'running') return 'cyan'
36  if (status === 'completed') return 'green'
37  if (status === 'failed' || status === 'error' || status === 'refusal') return 'red'
38  if (status === 'killed' || status === 'aborted') return 'yellow'
39  return 'white'
40}
41
42export function duration(ms: number): string {
43  if (ms < 1000) return `${Math.round(ms)}ms`
44  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
45  return `${Math.round(ms / 60_000)}m`
46}
47
48export function fillTone(percent: number | undefined): string {
49  if (percent == null) return 'white'
50  if (percent >= 90) return 'red'
51  if (percent >= 75) return 'magenta'
52  if (percent >= 50) return 'yellow'
53  return 'green'
54}
55
hooks/model.ts 80 lines
1export const MAIN = 'main'
2export const PANE = 'agents-side'
3
4export type AgentRow = {
5  id: string
6  parentId?: string
7  type: string
8  description: string
9  name?: string
10  status: string
11  prompt: string
12  model?: string
13  fork: boolean
14  background: boolean
15  toolCount: number
16  lastTools: string[]
17  durationMs?: number
18  answer?: string
19  inputTokens?: number
20  outputTokens?: number
21  percent?: number
22  window?: number
23}
24
25export type TreeNode = {
26  row: AgentRow
27  prefix: string
28  isLast: boolean
29}
30
31export function emptyMain(): AgentRow {
32  return {
33    id: MAIN,
34    type: 'main',
35    description: 'main loop',
36    status: 'idle',
37    prompt: '',
38    fork: false,
39    background: false,
40    toolCount: 0,
41    lastTools: [],
42  }
43}
44
45export function treeOf(rows: AgentRow[]): TreeNode[] {
46  const out: TreeNode[] = []
47  const kids = (parentId: string) =>
48    rows.filter(row => row.id !== MAIN && (row.parentId ?? MAIN) === parentId)
49
50  function walk(row: AgentRow, prefix: string, isLast: boolean) {
51    out.push({ row, prefix, isLast })
52    const childRows = kids(row.id)
53    childRows.forEach((child, i) => {
54      const last = i === childRows.length - 1
55      const branch = prefix === '' ? '' : `${prefix}${isLast ? '   ' : '│  '}`
56      walk(child, `${branch}${last ? '└─' : '├─'}`, last)
57    })
58  }
59
60  const main = rows.find(row => row.id === MAIN) ?? emptyMain()
61  walk(main, '', true)
62  for (const row of rows) {
63    if (row.id === MAIN) continue
64    if (out.some(node => node.row.id === row.id)) continue
65    walk(row, '└─', true)
66  }
67  return out
68}
69
70export function countsOf(rows: AgentRow[]) {
71  let running = 0
72  let done = 0
73  for (const row of rows) {
74    if (row.id === MAIN) continue
75    if (row.status === 'running') running += 1
76    else done += 1
77  }
78  return { running, done }
79}
80