SLOPSHOPPER

agent-dock

/dock N tells Claude to split work across N subagents; a band shows a live card per agent.

newbandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dock
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /dock ⎿ agent-dock: Agent Dock off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

agent-dock

Pick a team size and Claude splits the job across that many subagents. A band above the prompt shows a live line for each helper, with the model it runs on.

What it does

  • /dock 3 attaches a note to each prompt you send telling Claude to split separable work into at most 3 parallel subagents. The note is context the model reads; it is not shown in your message. /dock 0 turns it off. The size is 0 to 8.
  • Default size: the defaultSize option sets the team size each session starts with (0 to 8, default 0, which leaves the dock off until you use /dock). /dock overrides it for that session.
  • While anything besides the main session is running, a band above the prompt shows one line per worker:
╭──────────────────────────────────────────────────────────────╮
│ Agent Dock 3 running, 1 done                                 │
│ ▶ Main session [opus-5-5] main · 4 tools · Agent             │
│ ▶ Scan README  [haiku-4-5] agent · 6 tools · Glob            │
│ ▶ code task    [qwen2.5-coder:14b] ollama · 1 tools · Bash   │
│ ✔ Review diff  [sonnet-5-5] agent · 9 tools · Read           │
╰──────────────────────────────────────────────────────────────╯
  • Subagents: name, model, tool count and last tool, updated live. Running lines are colored; finished lines are green.
  • Ollama and Gemini: calls to mcp__ollama* and mcp__gemini__* tools get their own line, plus a Bash curl to the local Ollama API (port 11434). The model shows when the call names one.
  • Collapses when idle: the band is hidden unless a helper is running, and never takes more than 7 rows. It clears its lines on your next prompt.

Limits

  • A backgrounded Bash call has no completion event, so its Ollama line counts as running for its curl -m timeout (60 seconds if none is given).
  • Gemini's model shows only as gemini unless the call carries a model argument.
  • The note rides on each prompt, not the system prompt: since 2.1.296, Claude Code's built-in security plugin (cc-plugin-sec-default) skips a user mod's system-prompt hooks.

Install and run

claude --plugin-dir /path/to/claude-code-mods/agent-dock

To load it in every session, see Install.

To start every session with a team, set defaultSize in the pluginConfigs block of ~/.claude/settings.json, or change it in /config:

{
  "pluginConfigs": {
    "agent-dock": { "options": { "defaultSize": 8 } }
  }
}

Claude Code refuses new subagents past CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS (default 20). If you have lowered it, keep it at or above your team size.

Costs

The mod makes no model calls. While the dock is on, each prompt carries one short paragraph.

Source 2 files
hooks/register.tsx 170 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Card } from '../types'
5
6const ACCENT = '#bb9af7'
7const GREEN = '#9ece6a'
8const AMBER = '#e0af68'
9const KIND_COLOR = { main: '#7aa2f7', agent: '#7dcfff', ollama: '#ff9e64', gemini: '#f7768e' } as const
10
11// -1 until /dock is used this session; the defaultSize option applies until then.
12const size = atom({ plugin: 'agent-dock', key: 'size' } as const, -1)
13const cards = atom({ plugin: 'agent-dock', key: 'cards' } as const, {})
14
15const clamp = (n: number) => Math.max(0, Math.min(8, Math.trunc(n) || 0))
16
17const short = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
18
19// Gemini / Ollama calls, from MCP tools or a Bash curl to the local Ollama API.
20function external(tool: string, input: unknown): { kind: 'ollama' | 'gemini'; label: string; model: string } | null {
21  const args = (input ?? {}) as Record<string, unknown>
22  const str = (v: unknown) => (typeof v === 'string' ? v : '')
23
24  if (tool.startsWith('mcp__gemini__')) {
25    return { kind: 'gemini', label: tool.slice('mcp__gemini__gemini-'.length), model: str(args.model) || 'gemini' }
26  }
27
28  if (tool.startsWith('mcp__ollama')) {
29    return { kind: 'ollama', label: tool.replace(/^mcp__ollama[^_]*__(ollama_)?/, ''), model: str(args.model) || 'ollama' }
30  }
31
32  if (tool === 'Bash' && str(args.command).includes(':11434')) {
33    const m = /"model"\s*:\s*"([^"]+)"/.exec(str(args.command))
34
35    return { kind: 'ollama', label: str(args.description) || 'local call', model: m?.[1] ?? 'ollama' }
36  }
37
38  return null
39}
40
41export const register: Register = (on, options) => {
42  const defaultSize = clamp(Number(options.defaultSize ?? 0))
43
44  on('session.start', async ($, e, next) => {
45    await $.command.register({
46      name: 'dock',
47      description: 'Set team size: /dock 3 splits jobs across 3 subagents; /dock 0 turns it off',
48      argumentHint: '<team size>',
49    })
50
51    return next(e)
52  })
53
54  on('command.run', { command: 'dock' }, async ($, e) => {
55    const n = clamp(parseInt(String(e.args ?? '').trim(), 10))
56    await update($, size, () => n)
57
58    return { text: n ? `Agent Dock: team of ${n}.` : 'Agent Dock off.' }
59  })
60
61  // The team size rides on each prompt as context the model reads and the user never sees.
62  // Not a prompt.compose section: Claude Code's default security plugin skips user-tier
63  // system-prompt hooks (prompt.compose, prompt.context) since 2.1.296.
64  on('prompt.submit', async ($, e, next) => {
65    await update($, cards, () => ({}))
66    const set = await read($, size)
67    const n = set < 0 ? defaultSize : set
68
69    if (!n) {
70      return next(e)
71    }
72
73    const note = `Agent Dock: team size is ${n}. For any task with separable parts, split it into at most ${n} independent pieces and run them as ${n} parallel subagents (Agent tool, one message, background where possible), each with a short description. Give each a self-contained brief. Merge and verify the results yourself.`
74
75    return next({ ...e, context: [...(e.context ?? []), note] })
76  })
77
78  // Every request names its model, in the main loop or a subagent's.
79  on('turn.step', async function* ($, e, next) {
80    const key = e.agentId ?? 'main'
81    await update($, cards, all => {
82      const c: Card = all[key] ?? { kind: e.agentId ? 'agent' : 'main', label: e.agentId ? 'subagent' : 'Main session', model: '', tools: 0, last: '', isRunning: true }
83
84      return { ...all, [key]: { ...c, model: short(String(e.model)) } }
85    })
86
87    return yield* next(e)
88  })
89
90  on('tool.call', async ($, e, next) => {
91    const ext = external(String(e.tool), e)
92    const key = ext ? `ext:${e.tool_use_id}` : (e.agentId ?? 'main')
93
94    await update($, cards, all => {
95      const c: Card = all[key] ?? {
96        kind: ext ? ext.kind : e.agentId ? 'agent' : 'main',
97        label: ext ? ext.label : e.agentId ? 'subagent' : 'Main session',
98        model: ext ? ext.model : '',
99        tools: 0,
100        last: '',
101        isRunning: true,
102      }
103
104      return { ...all, [key]: { ...c, tools: c.tools + 1, last: String(e.tool).replace(/^mcp__/, ''), isRunning: true } }
105    })
106
107    const ran = await next(e)
108
109    if (ext) {
110      // simplified: a backgrounded Bash call has no completion event, so it counts as running for its curl -m timeout (default 60 s); upgrade: watch the task's output file
111      const bg = (ran as { result?: { backgroundTaskId?: string } } | undefined)?.result?.backgroundTaskId
112      const secs = Number(/\s-m\s+(\d+)/.exec(String((e as unknown as { command?: string }).command ?? ''))?.[1] ?? 60)
113      const endsAt = bg ? (await $.clock.now()) + secs * 1000 : undefined
114      await update($, cards, all => (all[key] ? { ...all, [key]: { ...all[key], isRunning: !!bg, endsAt } } : all))
115    }
116
117    return ran
118  })
119
120  // Collapses to nothing unless something besides the main session is running.
121  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
122    const all = await read($, cards)
123    const info = await $.agent.list()
124    const now = await $.clock.now()
125    const rows = Object.entries(all).map(([id, c]) => {
126      const a = info.find(x => x.id === id)
127
128      return {
129        ...c,
130        label: a ? a.description || a.name || a.type : c.label,
131        isRunning: c.kind === 'agent' ? (a ? a.status === 'running' : false) : c.kind === 'main' ? e.props.isWorking : c.isRunning && (c.endsAt === undefined || now < c.endsAt),
132      }
133    })
134    const helpers = rows.filter(r => r.kind !== 'main')
135    const running = helpers.filter(r => r.isRunning).length
136
137    if (e.props.hasSurvey || running === 0) {
138      return next(e)
139    }
140
141    const { Box, Text } = $.ui.resolve(e)
142    const room = Math.max(2, Math.min(7, e.props.maxRows - 4))
143    const order = [...rows].sort((a, b) => Number(b.kind === 'main') - Number(a.kind === 'main') || Number(b.isRunning) - Number(a.isRunning))
144    const shown = order.slice(0, room)
145
146    return (
147      <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
148        <Text>
149          <Text bold color={ACCENT}>Agent Dock </Text>
150          <Text color={AMBER}>{running} running</Text>
151          <Text dimColor>, {helpers.length - running} done</Text>
152        </Text>
153        {shown.map(r => {
154          const color = r.isRunning ? KIND_COLOR[r.kind] : GREEN
155
156          return (
157            <Text dimColor={!r.isRunning}>
158              <Text color={color}>{r.isRunning ? '▶ ' : '✔ '}</Text>
159              <Text bold={r.isRunning} color={color}>{r.label}</Text>
160              <Text color={KIND_COLOR[r.kind]}> [{r.model || '?'}]</Text>
161              <Text dimColor> {r.kind} · {r.tools} tools{r.last ? ` · ${r.last}` : ''}</Text>
162            </Text>
163          )
164        })}
165        {rows.length > shown.length && <Text dimColor>… {rows.length - shown.length} more</Text>}
166      </Box>
167    )
168  })
169}
170
types/index.d.ts 16 lines
1export type Card = {
2  kind: 'main' | 'agent' | 'ollama' | 'gemini'
3  label: string
4  model: string
5  tools: number
6  last: string
7  isRunning: boolean
8  endsAt?: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'agent-dock': { size: number; cards: Record<string, Card> }
14  }
15}
16