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

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.
/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.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.╭──────────────────────────────────────────────────────────────╮
│ 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 │
╰──────────────────────────────────────────────────────────────╯
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.curl -m timeout (60 seconds if none is given).gemini unless the call carries a model argument.cc-plugin-sec-default) skips a user mod's system-prompt hooks.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.
The mod makes no model calls. While the dock is on, each prompt carries one short paragraph.
hooks/register.tsx 170 lines1import { 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}
170types/index.d.ts 16 lines1export 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