SLOPSHOPPER

agent-dolls

Side pane with one little robot per agent (main and subagents); press one to see its conversation

newpanecommandprompttimeragents
★ 1v0.2.0MITupdated 2026-10-08visiblesoft-es/claude-mods/agent-dolls
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dolls
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ╷ │ ┃ ▗▄▄█▄▄▖ [ Main ] ⏺ Read(src/auth.ts) │ ┃ ▐ – – ▌ Main agent ⎿ Read 6 lines │ ┃ ▝▀▙▄▟▀▘ ▶ idle · 👁 on screen ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No subagents yet. ⏺ 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 │ │ › /dolls │ ⎿ agent-dolls: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
╷ ▗▄▄█▄▄▖ [ Main ] ▐ – – ▌ Main agent ▝▀▙▄▟▀▘ ▶ idle · 👁 on screen No subagents yet.
README

agent-dolls

A Claude Code mod that shows, in a side pane, one little robot for each agent in the session: the main agent and every subagent it launched. Press a robot and its conversation appears below, in the same pane.

     ✦
  ▗▄▄█▄▄▖   [ Main ]
  ▐ ● ● ▌   Main agent
  ▝▀▙▄▟▀▘   ▶ working

       ✦
    ▗▄▄█▄▄▖   [ Explore ]
    ▐ ● ● ▌   find endpoints
    ▝▀▙▄▟▀▘   working · 👁 on screen

       ╻
    ▗▄▄█▄▄▖   [ general-purpose ]
  ▝▘▐ ^ ^ ▌▝▘ migrate tests
    ▝▀▙▄▟▀▘   finished

Press a robot and the robots shrink to one line each, with its conversation below:

▐●●▌ [ Main ] working
  ▐●●▌ [ Explore ] ▶ working · 👁 on screen
  ▐^^▌ [ general-purpose ] finished
────────────────────────────────────────
▐●●▌ find endpoints              [ Close ]
» Find the endpoints
› Grep MapGet
Found 14 endpoints…

Installation

Requires Claude Code with mod support (tested on the latest version).

From a Claude Code session in the terminal:

/plugin install agent-dolls --marketplace visiblesoft-es/claude-mods

Answer y to add the marketplace and pick a scope (the user scope is the first one). The mod is active as soon as it is installed, with no restart.

Or from your shell:

claude plugin marketplace add visiblesoft-es/claude-mods
claude plugin install agent-dolls@claude-mods

To try it from a local clone, without installing:

git clone https://github.com/visiblesoft-es/claude-mods.git
claude --plugin-dir claude-mods/agent-dolls

To update or uninstall, see the repository README.

Usage

  • /dolls opens the pane. (It is not /agents, which is Claude Code's own command for managing subagents.)
  • Each robot shows the agent's type, its description and its status, and its face follows that status: eyes open ● ● while working (with a blinking antenna), – – idle, ^ ^ with raised arms when finished, × × when failed. Its color says the agent's type: Claude orange for the main agent, cyan for Explore, magenta for Plan, yellow for general-purpose. Subagents are indented under the agent that launched them.
  • Press a robot to see its conversation (messages and tools used) below; it refreshes every 1.5 s. Press it again, or Close or Main, to hide it.
  • 👁 on screen marks the agent whose conversation you have open in Claude Code's own view (the one you switch from the task bar below the prompt).

Limitations

  • A mod cannot switch Claude Code's main view to a subagent's; it can only read which one is on screen. That is why the conversation is shown inside the pane.
  • Claude Code shows one mod pane at a time (the others become tabs), so the robots and the conversation share the same pane.
  • Claude Code drops finished subagents from its list shortly after they end, and their robots go with them.
  • If a subagent finished without saving its conversation, the pane shows the reason instead of the messages.
  • The pane docks beside the conversation in fullscreen mode; otherwise it appears above the prompt.

Development

hooks/register.tsx        the pane's logic and drawing
hooks/lib/robot.ts        the robots, as pure functions
types/index.d.ts          the mod's state ($.state) contract
tests/agent-dolls.test.tsx
tests/robot.test.ts
claude plugin validate .
claude plugin test .

When Claude Code loads the mod it writes the API types into .claude-plugin/types/ (ignored by the repository's .gitignore); with them, tsc -p . type-checks the mod.

Built on Claude Code's mod API, which is in early access and may change between releases.

Source 3 files
hooks/register.tsx 249 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Detail, Doll, Line } from '../types'
5import { miniRobot, robot } from './lib/robot'
6
7const LIST = 'agent-dolls'
8const MAIN = 'main'
9const POLL_MS = 1500
10const MAX_LINES = 60
11
12const dolls = atom({ plugin: 'agent-dolls', key: 'dolls' } as const, [])
13const isMainBusy = atom({ plugin: 'agent-dolls', key: 'isMainBusy' } as const, false)
14const selected = atom({ plugin: 'agent-dolls', key: 'selected' } as const, null)
15const detail = atom({ plugin: 'agent-dolls', key: 'detail' } as const, null)
16const frame = atom({ plugin: 'agent-dolls', key: 'frame' } as const, 0)
17
18const STATUS: Record<string, { label: string; color: string }> = {
19  running: { label: 'working', color: 'success' },
20  pending: { label: 'starting', color: 'warning' },
21  waiting: { label: 'waiting', color: 'warning' },
22  idle: { label: 'idle', color: 'subtle' },
23  completed: { label: 'finished', color: 'subtle' },
24  failed: { label: 'failed', color: 'error' },
25  killed: { label: 'stopped', color: 'error' },
26}
27
28function toDoll(agent: AgentInfo): Doll {
29  return {
30    id: agent.id,
31    type: agent.type,
32    description: agent.description,
33    status: agent.status,
34    parentId: agent.parentId,
35  }
36}
37
38function clip(text: string, max: number): string {
39  const flat = text.replace(/\s+/g, ' ').trim()
40  return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
41}
42
43function briefInput(input: Record<string, unknown>): string {
44  const first = ['description', 'command', 'file_path', 'pattern', 'path', 'url', 'prompt']
45    .map(k => input[k])
46    .find(v => typeof v === 'string')
47  return typeof first === 'string' ? first : ''
48}
49
50function toLines(messages: SessionMessage[]): Line[] {
51  const lines: Line[] = []
52  for (const m of messages) {
53    if (m.text) lines.push({ role: m.role, text: m.text })
54    for (const use of m.toolUses) {
55      lines.push({
56        role: 'tool',
57        text: `${use.tool} ${briefInput(use.input)}`.trim(),
58        isError: use.isError === true,
59      })
60    }
61  }
62  return lines.slice(-MAX_LINES)
63}
64
65function same(a: unknown, b: unknown): boolean {
66  return JSON.stringify(a) === JSON.stringify(b)
67}
68
69async function refresh($: EngineInterface) {
70  const next = (await $.agent.list()).map(toDoll)
71  if (!same(next, await read($, dolls))) await update($, dolls, () => next)
72  // Only a working robot animates, so only then is it worth a redraw.
73  const isAnyWorking = (await read($, isMainBusy)) || next.some(d => d.status === 'running')
74  if (isAnyWorking) await update($, frame, n => n + 1)
75
76  const id = await read($, selected)
77  if (id === null || id === MAIN) return
78  const answer = await $.session.messages({ agentId: id })
79  const fresh: Detail = Array.isArray(answer)
80    ? { agentId: id, lines: toLines(answer) }
81    : { agentId: id, lines: (await read($, detail))?.lines ?? [], deny: answer.deny }
82  if (!same(fresh, await read($, detail))) await update($, detail, () => fresh)
83}
84
85async function select($: EngineInterface, doll: Doll | null) {
86  const isSame = doll !== null && (await read($, selected)) === doll.id
87  if (doll === null || isSame) {
88    await update($, selected, () => MAIN)
89    await update($, detail, () => null)
90    return
91  }
92  await update($, selected, () => doll.id)
93  await update($, detail, () => ({ agentId: doll.id, lines: [] }))
94  await refresh($)
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    await $.command.register({
100      name: 'dolls',
101      description: 'Open the pane with one doll for the main agent and each subagent',
102    })
103    $.clock.every(POLL_MS, () => void refresh($))
104
105    return next(e)
106  })
107
108  on('command.run', { command: 'dolls' }, async $ => {
109    await refresh($)
110    await $.ui.open({ id: LIST, title: 'Agents' })
111
112    return { text: 'Agents pane opened.' }
113  })
114
115  on('prompt.submit', async ($, e, next) => {
116    await update($, isMainBusy, () => true)
117    return next(e)
118  })
119
120  on('turn.complete', async ($, e, next) => {
121    if (e.agentId === undefined) await update($, isMainBusy, () => false)
122    void refresh($)
123    return next(e)
124  })
125
126  on('agent.spawn', async ($, e, next) => {
127    const spawned = await next(e)
128    void refresh($)
129    return spawned
130  })
131
132  on('ui.close', async ($, e, next) => {
133    if (e.id === LIST) await select($, null)
134    return next(e)
135  })
136
137  // A single pane: the terminal shows one mod pane at a time (the rest become
138  // tabs), so the robots and the conversation share it.
139  on('ui.render', { component: 'Pane', requestId: LIST }, async ($, e) => {
140    const { Box, Text, Button } = $.ui.resolve(e)
141    const list = await read($, dolls)
142    const current = (await read($, selected)) ?? MAIN
143    const tick = await read($, frame)
144    const shown = await read($, detail)
145    const viewed = e.props.view.agentId ?? MAIN
146    const main: Doll = {
147      id: MAIN,
148      type: 'main',
149      description: 'Main agent',
150      status: (await read($, isMainBusy)) ? 'running' : 'idle',
151    }
152    const columns = e.props.bodyColumns ?? 40
153    const width = Math.max(12, columns - 6)
154    const isCompact = shown !== null
155    const depth = (d: Doll): number =>
156      d.parentId ? 1 + depth(list.find(p => p.id === d.parentId) ?? { ...d, parentId: undefined }) : 1
157
158    const dollRow = (d: Doll) => {
159      const s = STATUS[d.status] ?? { label: d.status, color: 'subtle' }
160      const isCurrent = d.id === current
161      const indent = d.id === MAIN ? 0 : Math.min(depth(d), 3) * 2
162      const pick = (
163        <Button
164          key={`pick-${d.id}`}
165          label={d.id === MAIN ? 'Main' : d.type}
166          variant={isCurrent ? 'primary' : 'secondary'}
167          onPress={() => select($, d.id === MAIN ? null : d)}
168        />
169      )
170      const state = (
171        <Text color={s.color}>
172          {isCurrent ? '▶ ' : ''}
173          {s.label}
174          {d.id === viewed ? ' · 👁 on screen' : ''}
175        </Text>
176      )
177
178      if (isCompact) {
179        const mini = miniRobot(d.type, d.status, tick)
180        return (
181          <Box key={`doll-${d.id}`} flexDirection="row" gap={1} marginLeft={indent}>
182            <Text color={mini.color} dimColor={mini.isDim} bold={isCurrent}>{mini.rows[0]}</Text>
183            {pick}
184            {state}
185          </Box>
186        )
187      }
188
189      const bot = robot(d.type, d.status, tick)
190      return (
191        <Box key={`doll-${d.id}`} flexDirection="row" gap={1} marginLeft={indent}>
192          <Box flexDirection="column" flexShrink={0}>
193            {bot.rows.map((row, i) => (
194              <Text key={`r${i}`} color={bot.color} dimColor={bot.isDim} bold={isCurrent}>
195                {row}
196              </Text>
197            ))}
198          </Box>
199          <Box flexDirection="column" marginTop={1}>
200            {pick}
201            <Text dimColor wrap="truncate-end">{clip(d.description, width)}</Text>
202            {state}
203          </Box>
204        </Box>
205      )
206    }
207
208    const agents = [main, ...list]
209    const dollRows = isCompact ? agents.length : agents.length * 5
210    const room = Math.max(3, (e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30) - dollRows - 4)
211    const owner = shown ? list.find(d => d.id === shown.agentId) : undefined
212
213    return (
214      <Box flexDirection="column" gap={isCompact ? 0 : 1}>
215        {agents.map(dollRow)}
216        {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
217        {shown && (
218          <Box flexDirection="column" marginTop={1}>
219            <Text dimColor>{'─'.repeat(Math.max(4, columns - 2))}</Text>
220            <Box flexDirection="row" justifyContent="space-between">
221              <Box flexDirection="row" gap={1}>
222                <Text color={miniRobot(owner?.type ?? '', owner?.status ?? 'idle', tick).color}>
223                  {miniRobot(owner?.type ?? '', owner?.status ?? 'idle', tick).rows[0]}
224                </Text>
225                <Text bold wrap="truncate-end">{clip(owner?.description ?? 'Subagent', width - 16)}</Text>
226              </Box>
227              <Button key="back" label="Close" role="dismiss" onPress={() => select($, null)} />
228            </Box>
229            {shown.deny && <Text color="warning">{clip(shown.deny, width * 2)}</Text>}
230            {shown.lines.length === 0 && !shown.deny && <Text dimColor>Loading…</Text>}
231            {shown.lines.slice(-room).map(line =>
232              line.role === 'tool' ? (
233                <Text color={line.isError ? 'error' : 'subtle'} wrap="truncate-end">
234                  › {clip(line.text, width)}
235                </Text>
236              ) : (
237                <Text color={line.role === 'user' ? 'suggestion' : 'text'} wrap="wrap">
238                  {line.role === 'user' ? '» ' : ''}
239                  {clip(line.text, width * 3)}
240                </Text>
241              ),
242            )}
243          </Box>
244        )}
245      </Box>
246    )
247  })
248}
249
hooks/lib/robot.ts 68 lines
1// Block-character robots, one per agent: the face follows the agent's status,
2// the color its type. Every glyph is a single terminal cell wide.
3
4export type Robot = {
5  rows: string[]
6  color: string
7  isDim: boolean
8}
9
10// Width of the full robot: the head is 7 cells, plus 2 on each side for the
11// arms a finished robot raises.
12export const ROBOT_WIDTH = 11
13
14const TYPE_COLORS: Record<string, string> = {
15  main: 'claude',
16  Explore: 'cyan',
17  Plan: 'magenta',
18  'general-purpose': 'yellow',
19  teammate: 'blue',
20}
21
22type Face = { eyes: string; antenna: string; isCheering?: boolean }
23
24// `frame` ticks while an agent works: the antenna lights up and the eyes blink.
25function face(status: string, frame: number): Face {
26  switch (status) {
27    case 'running':
28      return frame % 4 === 3 ? { eyes: '▁ ▁', antenna: '╻' } : { eyes: '● ●', antenna: frame % 2 === 0 ? '✦' : '╻' }
29    case 'pending':
30      return { eyes: '◦ ◦', antenna: '╻' }
31    case 'waiting':
32      return { eyes: '● ●', antenna: '?' }
33    case 'completed':
34      return { eyes: '^ ^', antenna: '╻', isCheering: true }
35    case 'failed':
36    case 'killed':
37      return { eyes: '× ×', antenna: '╷' }
38    default:
39      return { eyes: '– –', antenna: '╷' }
40  }
41}
42
43export function robotColor(type: string, status: string): string {
44  if (status === 'failed' || status === 'killed') return 'red'
45  return TYPE_COLORS[type] ?? 'green'
46}
47
48export function robot(type: string, status: string, frame = 0): Robot {
49  const f = face(status, frame)
50  const arm = f.isCheering ? '▝▘' : '  '
51  return {
52    rows: [
53      `     ${f.antenna}     `,
54      '  ▗▄▄█▄▄▖  ',
55      `${arm}▐ ${f.eyes} ▌${arm}`,
56      '  ▝▀▙▄▟▀▘  ',
57    ],
58    color: robotColor(type, status),
59    isDim: status === 'idle' || status === 'completed',
60  }
61}
62
63// The one-line robot, for rows where the conversation takes the room below.
64export function miniRobot(type: string, status: string, frame = 0): Robot {
65  const eyes = face(status, frame).eyes.replace(' ', '')
66  return { rows: [`▐${eyes}▌`], color: robotColor(type, status), isDim: status === 'idle' || status === 'completed' }
67}
68
types/index.d.ts 32 lines
1export type Doll = {
2  id: string
3  type: string
4  description: string
5  status: string
6  parentId?: string
7}
8
9export type Line = {
10  role: 'user' | 'assistant' | 'tool'
11  text: string
12  isError?: boolean
13}
14
15export type Detail = {
16  agentId: string
17  lines: Line[]
18  deny?: string
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'agent-dolls': {
24      dolls: Doll[]
25      isMainBusy: boolean
26      selected: string | null
27      detail: Detail | null
28      frame: number
29    }
30  }
31}
32