Live panel showing parallel helper agents — set team size, watch cards fill in real-time

hooks/register.tsx 7 lines1import type { Register } from 'claude-code'
2import { registerDock } from './dock'
3
4export const register: Register = on => {
5 registerDock(on)
6}
7hooks/dock.tsx 761 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3import {
4 parseSize, jobName, initials, colorFor,
5 splitInstructionWithRoles, teamInstructionWithRoles, nextRole, roleInitials,
6 nudgeText, badgeText, summaryLine,
7 meterBar, fmtTime, fmtTimer, overallPct,
8} from './dock-logic'
9
10const PANE = 'agent-dock'
11const MAX_CONCURRENT = 10
12let dataDir = ''
13
14// ── Types ──────────────────────────────────────────────────────────────────────
15
16type HelperStatus = 'queued' | 'working' | 'done' | 'stuck'
17type AgentRole = { name: string; instructions: string }
18type Helper = {
19 id: string
20 description: string
21 initials: string
22 color: string
23 status: HelperStatus
24 pct: number
25 startMs: number | null
26 endMs: number | null
27}
28type DockPhase = 'idle' | 'live' | 'done'
29type DockMode = 'parallel' | 'team'
30type DockState = {
31 teamSize: number
32 helperModel: 'haiku' | 'same'
33 mode: DockMode
34 job: string
35 helpers: Helper[]
36 phase: DockPhase
37 jobStartMs: number | null
38 jobEndMs: number | null
39 isFolded: boolean
40 pendingBigTeam: number | null
41 stuckCount: number
42 roles: AgentRole[]
43}
44
45// ── Atoms ─────────────────────────────────────────────────────────────────────
46
47const dockAtom = atom({ plugin: 'agent-dock', key: 'dock' } as const, {
48 teamSize: 1,
49 helperModel: 'haiku' as const,
50 mode: 'parallel' as DockMode,
51 job: '',
52 helpers: [] as Helper[],
53 phase: 'idle' as DockPhase,
54 jobStartMs: null as number | null,
55 jobEndMs: null as number | null,
56 isFolded: false,
57 pendingBigTeam: null as number | null,
58 stuckCount: 0,
59 roles: [] as AgentRole[],
60} satisfies DockState)
61
62const tickAtom = atom({ plugin: 'agent-dock', key: 'tick' } as const, 0)
63
64// ── Module-level ephemeral state ───────────────────────────────────────────────
65
66let activeCount = 0
67let turnAgentCount = 0
68let nudgeSentThisTurn = false
69let clockCancel: (() => void) | undefined
70let sessionId = ''
71const agentIdToCardId = new Map<string, string>()
72
73// ── Helpers ────────────────────────────────────────────────────────────────────
74
75async function writeStatusFile($: Parameters<Parameters<On>[2]>[0]): Promise<void> {
76 if (!sessionId) return
77 try {
78 const state = await read($, dockAtom)
79 const working = state.helpers.filter(h => h.status === 'working').length
80 const queued = state.helpers.filter(h => h.status === 'queued').length
81 const done = state.helpers.filter(h => h.status === 'done').length
82 await $.fs.write(
83 `${dataDir}/${sessionId}.json`,
84 JSON.stringify({ working, queued, done, total: state.helpers.length }),
85 )
86 } catch { /* non-critical */ }
87}
88
89// ── Register ───────────────────────────────────────────────────────────────────
90
91export function registerDock(on: On): void {
92
93 // ── Session start ──────────────────────────────────────────────────────────
94 on('session.start', async ($, e, next) => {
95 activeCount = 0
96 turnAgentCount = 0
97 nudgeSentThisTurn = false
98 agentIdToCardId.clear()
99 clockCancel?.()
100 clockCancel = undefined
101
102 try { sessionId = await $.session.id() } catch { sessionId = '' }
103 try {
104 const r = await $.process.run(['sh', '-c', 'echo $HOME'])
105 dataDir = `${r.stdout.trim()}/.claude/ai-employee-kit-data/agents-now`
106 } catch { dataDir = '' }
107
108 // Load persisted settings
109 try {
110 const size = await $.store.get({ key: 'dock-team-size' })
111 const hm = await $.store.get({ key: 'dock-helper-model' })
112 const storedRoles = await $.store.get({ key: 'dock-roles' })
113 const storedMode = await $.store.get({ key: 'dock-mode' })
114 const storedSize = typeof size?.value === 'number' ? size.value : 1
115 // Big team sizes (>20) never carry over
116 const safeSize = storedSize > 20 ? 1 : storedSize
117 const helperModel = hm?.value === 'same' ? 'same' as const : 'haiku' as const
118 const mode: DockMode = storedMode?.value === 'team' ? 'team' : 'parallel'
119 const roles = Array.isArray(storedRoles?.value) ? storedRoles.value as AgentRole[] : []
120 await update($, dockAtom, s => ({ ...s, teamSize: safeSize, helperModel, mode, roles }))
121 } catch { /* keep defaults */ }
122
123 try {
124 await $.command.register({
125 name: 'dock',
126 description: 'Open Agent Dock. /dock N sets team size.',
127 argumentHint: '[N|on|off]',
128 immediate: true,
129 })
130 } catch (err) {
131 $.ui.log(`agent-dock: /dock register failed (${String(err)})`, { to: 'debug' })
132 }
133
134 return next(e)
135 })
136
137 // ── /dock command ──────────────────────────────────────────────────────────
138 on('command.run', { command: 'dock' }, async ($, e) => {
139 const args = (e.args ?? '').trim()
140 const state = await read($, dockAtom)
141
142 // /dock parallel | /dock team — switch mode
143 if (args === 'parallel' || args === 'team') {
144 const mode: DockMode = args
145 await update($, dockAtom, s => ({ ...s, mode }))
146 try { await $.store.set({ key: 'dock-mode', value: mode }) } catch { /* ignore */ }
147 $.ui.toast(`Mode: ${mode === 'team' ? '🔗 Team (sequential)' : '⚡ Parallel'}`)
148 return { text: `Dock mode set to ${mode}.` }
149 }
150
151 // /dock role N name ["instructions"] — assign role to a slot
152 if (args.startsWith('role ')) {
153 const rest = args.slice(5).trim()
154 const numMatch = rest.match(/^(\d+)\s+(.+)$/)
155 if (!numMatch) return { text: 'Usage: /dock role N rolename ["instructions"] e.g. /dock role 1 backend "use REST"' }
156 const slot = parseInt(numMatch[1]!, 10) - 1
157 if (slot < 0 || slot >= 100) return { text: 'Slot must be 1–100.' }
158 const remainder = numMatch[2]!.trim()
159 const instrMatch = remainder.match(/^(.*?)\s*"([^"]*)"$/)
160 const roleName = instrMatch ? instrMatch[1]!.trim() : remainder
161 const instructions = instrMatch ? instrMatch[2]! : ''
162 await update($, dockAtom, s => {
163 const roles = [...s.roles]
164 while (roles.length <= slot) roles.push({ name: '', instructions: '' })
165 roles[slot] = { name: roleName, instructions }
166 return { ...s, roles }
167 })
168 try {
169 const s2 = await read($, dockAtom)
170 await $.store.set({ key: 'dock-roles', value: s2.roles })
171 } catch { /* ignore */ }
172 return { text: `Agent ${slot + 1}: ${roleName}${instructions ? ` — "${instructions}"` : ''}` }
173 }
174
175 // /dock N — set size
176 if (args !== '') {
177 const n = parseSize(args)
178 if (n === null) {
179 return { text: 'Team Size is a whole number from 1 to 100, e.g. /dock 10.' }
180 }
181 if (n > 20) {
182 await update($, dockAtom, s => ({ ...s, pendingBigTeam: n }))
183 void (async () => {
184 try {
185 const panes = await $.ui.panes()
186 if (!panes.some(p => p.id === PANE)) {
187 const res = await $.ui.open({ id: PANE, title: 'Agent Dock', focus: true, closeOnEscape: true, columns: 54 })
188 if (!res.isPlaced) $.ui.toast('Widen the window to see Agent Dock.')
189 }
190 } catch { /* ignore */ }
191 })()
192 return { text: `Confirm the team of ${n} in the Agent Dock.` }
193 }
194 await update($, dockAtom, s => ({ ...s, teamSize: n, pendingBigTeam: null }))
195 try { await $.store.set({ key: 'dock-team-size', value: n }) } catch { /* ignore */ }
196 $.ui.toast(`Team size: ${n}`)
197 void (async () => {
198 try {
199 const panes = await $.ui.panes()
200 if (!panes.some(p => p.id === PANE)) {
201 const res = await $.ui.open({ id: PANE, title: 'Agent Dock', focus: true, closeOnEscape: true, columns: 54 })
202 if (!res.isPlaced) $.ui.toast('Widen the window to see Agent Dock.')
203 }
204 } catch { /* ignore */ }
205 })()
206 return { text: `Team size set to ${n}.` }
207 }
208
209 // /dock — toggle
210 void (async () => {
211 try {
212 const panes = await $.ui.panes()
213 const isOpen = panes.some(p => p.id === PANE)
214 if (isOpen) {
215 await $.ui.close({ id: PANE })
216 await update($, dockAtom, s => ({ ...s, isFolded: true }))
217 } else {
218 await update($, dockAtom, s => ({ ...s, isFolded: false }))
219 const res = await $.ui.open({ id: PANE, title: 'Agent Dock', focus: true, closeOnEscape: true, columns: 54 })
220 if (!res.isPlaced) $.ui.toast('The window is too narrow to show the Agent Dock. Widen it or watch the status bar.')
221 }
222 } catch { /* ignore */ }
223 })()
224
225 const isFolded = state.isFolded
226 const working = state.helpers.filter(h => h.status === 'working').length
227 const queued = state.helpers.filter(h => h.status === 'queued').length
228 const done = state.helpers.filter(h => h.status === 'done').length
229 return { text: isFolded ? 'Agent Dock opened.' : badgeText(working, queued, done) }
230 })
231
232 // ── ui.close — pane closed (X or ESC) ─────────────────────────────────────
233 on('ui.close', { id: PANE }, async ($, e, next) => {
234 const result = await next(e)
235 await update($, dockAtom, s => ({ ...s, isFolded: true }))
236 return result
237 })
238
239 // ── prompt.submit — inject split instruction ───────────────────────────────
240 on('prompt.submit', async ($, e, next) => {
241 if ((e as unknown as Record<string, unknown>)['agentId'] !== undefined) return next(e)
242
243 const state = await read($, dockAtom)
244 const { teamSize } = state
245
246 // Reset per-turn counters
247 turnAgentCount = 0
248 nudgeSentThisTurn = false
249
250 const job = jobName(e.text)
251 const now = await $.clock.now()
252 await update($, dockAtom, s => ({
253 ...s,
254 job,
255 helpers: [],
256 phase: 'idle',
257 jobStartMs: now,
258 jobEndMs: null,
259 stuckCount: 0,
260 }))
261
262 if (teamSize <= 1) return next(e)
263
264 // Build per-slot roles, cycling if fewer defined than teamSize
265 const { roles, helperModel, mode } = state
266 const effectiveRoles = Array.from({ length: teamSize }, (_, i) =>
267 roles.length > 0 ? (roles[i % roles.length] ?? { name: '', instructions: '' }) : { name: '', instructions: '' }
268 )
269 const instruction = mode === 'team'
270 ? teamInstructionWithRoles(teamSize, effectiveRoles, helperModel)
271 : splitInstructionWithRoles(teamSize, effectiveRoles, helperModel)
272 return next({ ...e, context: [...(e.context ?? []), instruction] })
273 })
274
275 // ── turn.start — start tick clock ─────────────────────────────────────────
276 on('turn.start', async ($, e, next) => {
277 if ((e as unknown as Record<string, unknown>)['agentId'] !== undefined) return next(e)
278
279 if (!clockCancel) {
280 clockCancel = $.clock.every(500, async () => {
281 const state = await read($, dockAtom)
282 if (state.phase !== 'live') return
283 await update($, tickAtom, t => ((t ?? 0) + 1) % 1000)
284 if (state.isFolded) {
285 const w = state.helpers.filter(h => h.status === 'working').length
286 const q = state.helpers.filter(h => h.status === 'queued').length
287 const d = state.helpers.filter(h => h.status === 'done').length
288 $.ui.status(badgeText(w, q, d))
289 }
290 void writeStatusFile($)
291 })
292 }
293
294 return next(e)
295 })
296
297 // ── agent.spawn — queue, cap, track ───────────────────────────────────────
298 on('agent.spawn', async ($, e, next) => {
299 // Only intercept top-level (main-thread) spawns
300 if (e.parentAgentId !== undefined) return next(e)
301
302 const state = await read($, dockAtom)
303 const { teamSize, helperModel } = state
304
305 turnAgentCount++
306
307 // Cap check only when team size is explicit
308 if (teamSize > 1 && turnAgentCount > teamSize) {
309 return { deny: `Team Size is ${teamSize}: this request already has ${teamSize} helpers. Finish with the helpers you have.` }
310 }
311
312 // Add queued card — use assigned role if available (only meaningful when teamSize > 1)
313 const cardId = e.tool_use_id
314 const slotIndex = turnAgentCount - 1
315 const roleForSlot = teamSize > 1 && state.roles.length > 0
316 ? (state.roles[slotIndex % state.roles.length] ?? { name: '', instructions: '' })
317 : { name: '', instructions: '' }
318 const desc = roleForSlot.name || e.description || `Helper ${turnAgentCount}`
319 const inits = roleForSlot.name ? roleInitials(roleForSlot.name) : initials(desc)
320 const color = colorFor(inits)
321 const helper: Helper = {
322 id: cardId,
323 description: desc,
324 initials: inits,
325 color,
326 status: 'queued',
327 pct: 0,
328 startMs: null,
329 endMs: null,
330 }
331 await update($, dockAtom, s => ({
332 ...s,
333 helpers: [...s.helpers, helper],
334 phase: s.phase === 'idle' ? 'live' : s.phase,
335 }))
336
337 // Queue while over concurrent limit
338 while (activeCount >= MAX_CONCURRENT) {
339 await $.process.run(['/bin/sleep', '0.5'])
340 }
341
342 activeCount++
343 const nowMs = await $.clock.now()
344 await update($, dockAtom, s => ({
345 ...s,
346 helpers: s.helpers.map(h => h.id === cardId
347 ? { ...h, status: 'working' as HelperStatus, startMs: nowMs }
348 : h),
349 }))
350
351 // Override model for Fast & Cheap
352 let ev: typeof e = e
353 if (helperModel === 'haiku' && !e.model) {
354 ev = { ...e, model: 'claude-haiku-4-5-20251001' }
355 }
356
357 const result = await next(ev)
358 // Track agentId for turn.complete mapping
359 if (!result.deny && result.agentId) {
360 agentIdToCardId.set(result.agentId, cardId)
361 } else {
362 // spawn refused — card goes stuck, free the slot
363 activeCount--
364 const endMs = await $.clock.now()
365 await update($, dockAtom, s => ({
366 ...s,
367 helpers: s.helpers.map(h => h.id === cardId
368 ? { ...h, status: 'stuck' as HelperStatus, endMs }
369 : h),
370 stuckCount: s.stuckCount + 1,
371 }))
372 }
373
374 return result
375 })
376
377 // ── turn.complete — subagent cards + main nudge/summary ──────────────────
378 on('turn.complete', async ($, e, next) => {
379 const agId = e.agentId
380
381 // Subagent finished — update its card
382 if (agId) {
383 const cardId = agentIdToCardId.get(agId)
384 if (cardId) {
385 agentIdToCardId.delete(agId)
386 activeCount = Math.max(0, activeCount - 1)
387 const endMs = await $.clock.now()
388 const status: HelperStatus = e.isAborted ? 'stuck' : 'done'
389 await update($, dockAtom, s => ({
390 ...s,
391 helpers: s.helpers.map(h => h.id === cardId
392 ? { ...h, status, pct: status === 'done' ? 100 : h.pct, endMs }
393 : h),
394 stuckCount: status === 'stuck' ? s.stuckCount + 1 : s.stuckCount,
395 }))
396 void writeStatusFile($)
397 }
398 return next(e)
399 }
400
401 // Main turn finished
402 const state = await read($, dockAtom)
403 const { teamSize, job, jobStartMs } = state
404
405 const nowMs = await $.clock.now()
406 const durationMs = jobStartMs !== null ? nowMs - jobStartMs : 0
407
408 if (teamSize > 1 && turnAgentCount > 0 && turnAgentCount < teamSize && !nudgeSentThisTurn) {
409 nudgeSentThisTurn = true
410 void $.prompt.submit({ text: nudgeText(turnAgentCount, teamSize) })
411 }
412
413 if (turnAgentCount > 0) {
414 await update($, dockAtom, s => ({
415 ...s,
416 phase: 'done' as DockPhase,
417 jobEndMs: nowMs,
418 helpers: s.helpers.map(h =>
419 h.status === 'queued' || h.status === 'working'
420 ? { ...h, status: 'done' as HelperStatus, pct: 100 }
421 : h,
422 ),
423 }))
424 clockCancel?.()
425 clockCancel = undefined
426 const finalState = await read($, dockAtom)
427 $.ui.toast(summaryLine(finalState.helpers.length, job, durationMs, finalState.stuckCount))
428 $.ui.status(undefined)
429 } else {
430 await update($, dockAtom, s => ({ ...s, phase: 'idle' }))
431 }
432 void writeStatusFile($)
433
434 return next(e)
435 })
436
437 // ── tool.call report_progress from helpers ─────────────────────────────────
438 on('tool.call', { tool: /report_progress/ as unknown as 'mcp__clean-view__report_progress' }, async ($, e, next) => {
439 const agId = (e as unknown as Record<string, unknown>)['agentId'] as string | undefined
440 if (!agId) return next(e)
441
442 const cardId = agentIdToCardId.get(agId)
443 const input = e as unknown as { task?: unknown; percent?: unknown }
444 const rawPct = Number(input.percent ?? 0)
445 const pct = Math.max(0, Math.min(100, Number.isFinite(rawPct) ? rawPct : 0))
446
447 if (cardId) {
448 await update($, dockAtom, s => ({
449 ...s,
450 helpers: s.helpers.map(h =>
451 h.id === cardId ? { ...h, pct, status: 'working' as HelperStatus } : h,
452 ),
453 }))
454 }
455
456 return { result: `Progress noted: ${pct}%.` }
457 })
458
459 // ── ui.render Pane ─────────────────────────────────────────────────────────
460 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
461 const { Box, Text, Button } = $.ui.resolve(e)
462
463 const state = await read($, dockAtom)
464 const tick = (await read($, tickAtom)) ?? 0
465 const nowMs = await $.clock.now()
466 const cols = e.props.bodyColumns
467 const { teamSize, helpers, phase, job, jobStartMs, jobEndMs, pendingBigTeam, helperModel, mode, roles } = state
468
469 // ── Big team confirmation ──────────────────────────────────────────────
470 if (pendingBigTeam !== null) {
471 const confirmBig = async () => {
472 await update($, dockAtom, s => ({ ...s, teamSize: pendingBigTeam!, pendingBigTeam: null }))
473 try { await $.store.set({ key: 'dock-team-size', value: pendingBigTeam! }) } catch { /* ignore */ }
474 $.ui.toast(`Team size: ${pendingBigTeam}`)
475 }
476 const cancelBig = async () => {
477 await update($, dockAtom, s => ({ ...s, pendingBigTeam: null }))
478 }
479 return (
480 <Box flexDirection="column" paddingX={1} paddingY={1}>
481 <Text bold>◆ A G E N T D O C K</Text>
482 <Text> </Text>
483 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
484 <Text color="yellow" bold>⚠ Big team: this uses your plan quickly.</Text>
485 <Text>Running {pendingBigTeam} helpers per request.</Text>
486 <Text> </Text>
487 <Box flexDirection="row" gap={2}>
488 <Button key="confirm" label={`Continue with ${pendingBigTeam}`} onPress={confirmBig} />
489 <Button key="cancel" label="Cancel" onPress={cancelBig} />
490 </Box>
491 </Box>
492 </Box>
493 )
494 }
495
496 // ── Team size presets ──────────────────────────────────────────────────
497 // hotkeys: 1 3 5 t(en) w(enty) f(ifty) c(entury)
498 const SIZES = [1, 3, 5, 10, 20, 50, 100]
499 const SIZE_KEYS = ['1', '3', '5', 't', 'w', 'f', 'c'] as const
500 const sizeButtons = SIZES.map((n, idx) => {
501 const setSize = async () => {
502 if (n > 20) {
503 await update($, dockAtom, s => ({ ...s, pendingBigTeam: n }))
504 return
505 }
506 await update($, dockAtom, s => ({ ...s, teamSize: n, pendingBigTeam: null }))
507 try { await $.store.set({ key: 'dock-team-size', value: n }) } catch { /* ignore */ }
508 $.ui.toast(`Team size: ${n}`)
509 }
510 const isActive = teamSize === n
511 return (
512 <Button
513 key={`sz-${n}`}
514 label={isActive ? `[${n}]` : ` ${n} `}
515 hotkey={SIZE_KEYS[idx]}
516 {...(isActive ? { autoFocus: true as const } : {})}
517 onPress={setSize}
518 />
519 )
520 })
521
522 const toggleModel = async () => {
523 const next: 'haiku' | 'same' = helperModel === 'haiku' ? 'same' : 'haiku'
524 await update($, dockAtom, s => ({ ...s, helperModel: next }))
525 try { await $.store.set({ key: 'dock-helper-model', value: next }) } catch { /* ignore */ }
526 }
527
528 const toggleMode = async () => {
529 const next: DockMode = mode === 'parallel' ? 'team' : 'parallel'
530 await update($, dockAtom, s => ({ ...s, mode: next }))
531 try { await $.store.set({ key: 'dock-mode', value: next }) } catch { /* ignore */ }
532 $.ui.toast(next === 'team' ? 'Team mode: agents chain in sequence' : 'Parallel mode: agents run at once')
533 }
534
535 const infoLine = teamSize === 1
536 ? 'Claude decides how many helpers'
537 : mode === 'team'
538 ? `Chains ${teamSize} specialists in sequence — each builds on the last`
539 : `Splits each request across ${teamSize} helpers · ${Math.min(teamSize, MAX_CONCURRENT)} at a time · ${helperModel === 'haiku' ? 'Fast & Cheap' : 'Same model as you'}`
540
541 const working = helpers.filter(h => h.status === 'working').length
542 const queued = helpers.filter(h => h.status === 'queued').length
543 const done = helpers.filter(h => h.status === 'done').length
544 const stuck = helpers.filter(h => h.status === 'stuck').length
545
546 const liveLabel = phase === 'live' ? '● LIVE' : phase === 'done' ? 'COMPLETE' : 'STANDING BY'
547 const liveColor = phase === 'live' ? 'green' : phase === 'done' ? 'green' : 'gray'
548
549 // ── Card rendering ─────────────────────────────────────────────────────
550 const cardWidth = cols > 90 ? 32 : 28
551 const cardsPerRow = Math.max(1, Math.floor(cols / (cardWidth + 1)))
552 const useSmallCards = helpers.length > 12
553
554 const renderCard = (h: Helper) => {
555 const isWorking = h.status === 'working'
556 const isDone = h.status === 'done'
557 const isStuck = h.status === 'stuck'
558 const isQueued = h.status === 'queued'
559 const timer = fmtTimer(h.startMs, isDone || isStuck ? (h.endMs ?? nowMs) : nowMs)
560 const sweepPct = isWorking && h.pct === 0 ? ((tick % 10) * 10) : h.pct
561
562 if (useSmallCards) {
563 // Compact tile for 13+ helpers
564 const statusChar = isDone ? '✓' : isStuck ? '✕' : isWorking ? '●' : '○'
565 const desc = h.description.length > 14 ? h.description.slice(0, 13) + '…' : h.description
566 return (
567 <Box key={h.id} flexDirection="row" width={18} borderStyle={isStuck ? 'bold' : 'single'}
568 borderColor={isStuck ? 'red' : isDone ? 'green' : isWorking ? undefined : undefined}
569 dimColor={isQueued}>
570 <Text color={h.color} bold> {h.initials}</Text>
571 <Text dimColor={isQueued}> {statusChar} </Text>
572 <Text dimColor={isQueued}>{desc}</Text>
573 </Box>
574 )
575 }
576
577 return (
578 <Box key={h.id} flexDirection="column" width={cardWidth}
579 borderStyle="single"
580 borderColor={isStuck ? 'red' : isDone ? 'green' : undefined}
581 dimColor={isQueued}>
582 <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
583 <Box flexDirection="row">
584 <Text color={h.color} bold>{h.initials}</Text>
585 <Text> </Text>
586 <Text dimColor={isQueued} bold={isWorking}>
587 {h.description.length > cardWidth - 8
588 ? h.description.slice(0, cardWidth - 9) + '…'
589 : h.description}
590 </Text>
591 </Box>
592 <Text dimColor>{timer}</Text>
593 </Box>
594 <Box paddingX={1}>
595 <Text color={isDone ? 'green' : isStuck ? 'red' : undefined} dimColor={isQueued}>
596 {meterBar(isDone ? 100 : sweepPct, cardWidth - 4)}
597 </Text>
598 <Text dimColor> {isDone ? '100%' : isQueued ? ' --' : `${h.pct}%`}</Text>
599 </Box>
600 </Box>
601 )
602 }
603
604 // ── Idle state ─────────────────────────────────────────────────────────
605 const renderIdle = () => {
606 const seats = Math.min(teamSize, 25)
607 const seatChars = Array.from({ length: seats }, (_, i) => {
608 const c = tick % (seats * 3)
609 return c === i ? '◆' : '◇'
610 }).join(' ')
611
612 const assignedRoles = roles.filter(r => r.name).map(r => r.name)
613 return (
614 <Box flexDirection="column" paddingX={1} gap={1}>
615 <Text dimColor>{seatChars}</Text>
616 <Text>Your team of {teamSize} is standing by</Text>
617 {assignedRoles.length > 0
618 ? <Text dimColor>Specialists: {assignedRoles.join(' · ')}</Text>
619 : <Text dimColor>Send a request and it {mode === 'team' ? 'chains through' : 'splits across'} {teamSize} helpers.</Text>
620 }
621 </Box>
622 )
623 }
624
625 // ── Group cards into rows ──────────────────────────────────────────────
626 const cardRows: Helper[][] = []
627 for (let i = 0; i < helpers.length; i += cardsPerRow) {
628 cardRows.push(helpers.slice(i, i + cardsPerRow))
629 }
630
631 const missionMs = phase === 'live'
632 ? (jobStartMs !== null ? nowMs - jobStartMs : 0)
633 : (jobEndMs !== null && jobStartMs !== null ? jobEndMs - jobStartMs : 0)
634 const pct = overallPct(helpers)
635
636 return (
637 <Box flexDirection="column" paddingX={1}>
638 {/* Masthead */}
639 <Box flexDirection="row" justifyContent="space-between" width={cols - 2}>
640 <Text bold color="yellow">◆ A G E N T D O C K</Text>
641 <Text color={liveColor}>{liveLabel}</Text>
642 </Box>
643
644 {/* Divider */}
645 <Text dimColor>{'─'.repeat(cols - 3)}</Text>
646
647 {/* Team size row */}
648 <Box flexDirection="row" gap={1} flexWrap="wrap">
649 <Text dimColor>T E A M S I Z E </Text>
650 {sizeButtons}
651 </Box>
652 <Text dimColor>{infoLine}</Text>
653 <Text dimColor> No keys? Ctrl+X then Tab to focus · 1 3 5 t w f c for size · m model · x mode</Text>
654
655 {/* Mode toggle */}
656 <Box flexDirection="row" gap={1}>
657 <Text dimColor>Mode: </Text>
658 <Button key="mode-parallel" hotkey="x"
659 label={mode === 'parallel' ? '⚡ Parallel ✓' : '⚡ Parallel'}
660 onPress={toggleMode} />
661 <Button key="mode-team"
662 label={mode === 'team' ? '🔗 Team ✓' : '🔗 Team'}
663 onPress={toggleMode} />
664 </Box>
665
666 {/* Model toggle */}
667 <Box flexDirection="row" gap={1}>
668 <Text dimColor>Helpers: </Text>
669 <Button key="model" hotkey="m" label={helperModel === 'haiku' ? 'Fast & Cheap ✓' : 'Same model as you ✓'} onPress={toggleModel} />
670 </Box>
671
672 {/* Role assignment (only when team > 1) */}
673 {teamSize > 1 && (
674 <Box flexDirection="column">
675 <Text dimColor>{'─'.repeat(cols - 3)}</Text>
676 <Text dimColor>R O L E S (optional)</Text>
677 <Text dimColor> Click preset or type: /dock role N "your role" ["instructions"]</Text>
678 {Array.from({ length: Math.min(teamSize, 10) }, (_, i) => {
679 const role = roles[i] ?? { name: '', instructions: '' }
680 const cycleRole = async () => {
681 const nextName = nextRole(role.name)
682 await update($, dockAtom, s => {
683 const r = [...s.roles]
684 while (r.length <= i) r.push({ name: '', instructions: '' })
685 r[i] = { ...r[i]!, name: nextName }
686 return { ...s, roles: r }
687 })
688 try {
689 const s2 = await read($, dockAtom)
690 await $.store.set({ key: 'dock-roles', value: s2.roles })
691 } catch { /* ignore */ }
692 }
693 const editHint = () => {
694 $.ui.toast(`Type: /dock role ${i + 1} "your role name" (optionally add "instructions" at end)`)
695 }
696 return (
697 <Box key={`role-${i}`} flexDirection="row" gap={1}>
698 <Text dimColor>#{i + 1}</Text>
699 <Button key={`r-${i}`} label={role.name ? `← ${role.name} →` : '← preset →'} onPress={cycleRole} />
700 <Button key={`e-${i}`} label="✏" onPress={editHint} />
701 {role.instructions && (
702 <Text dimColor>"{role.instructions.length > 20 ? role.instructions.slice(0, 19) + '…' : role.instructions}"</Text>
703 )}
704 </Box>
705 )
706 })}
707 {teamSize > 10 && (
708 <Text dimColor> + {teamSize - 10} more — use /dock role N to assign beyond #10</Text>
709 )}
710 {roles.some(r => r.name) && (
711 <Button key="clear-roles" label="Clear all roles" onPress={async () => {
712 await update($, dockAtom, s => ({ ...s, roles: [] }))
713 try { await $.store.set({ key: 'dock-roles', value: [] }) } catch { /* ignore */ }
714 }} />
715 )}
716 </Box>
717 )}
718
719 <Text dimColor>{'─'.repeat(cols - 3)}</Text>
720
721 {/* Mission bar (when live or done) */}
722 {(phase === 'live' || phase === 'done') && helpers.length > 0 && (
723 <Box flexDirection="column">
724 <Box flexDirection="row" justifyContent="space-between">
725 <Text bold>{job.length > cols - 20 ? job.slice(0, cols - 21) + '…' : job}</Text>
726 <Text color={phase === 'done' ? 'green' : undefined}>{pct}% {fmtTime(missionMs)}</Text>
727 </Box>
728 <Text color={phase === 'done' ? 'green' : 'cyan'}>{meterBar(pct, cols - 4)}</Text>
729 <Box flexDirection="row" gap={3}>
730 <Text color="green">● {working} working</Text>
731 <Text dimColor>○ {queued} queued</Text>
732 <Text color="green">✓ {done} done</Text>
733 {stuck > 0 && <Text color="red">✕ {stuck} stuck</Text>}
734 </Box>
735 <Text dimColor>{'─'.repeat(cols - 3)}</Text>
736 </Box>
737 )}
738
739 {/* Done summary */}
740 {phase === 'done' && helpers.length > 0 && (
741 <Box borderStyle="single" borderColor="green" paddingX={1}>
742 <Text color="green">{summaryLine(helpers.length, job, missionMs, state.stuckCount)}</Text>
743 </Box>
744 )}
745
746 {/* Cards / idle */}
747 {phase === 'idle' || helpers.length === 0
748 ? renderIdle()
749 : cardRows.map((row, ri) => (
750 <Box key={`row-${ri}`} flexDirection="row" gap={1} flexWrap="wrap">
751 {row.map(renderCard)}
752 </Box>
753 ))
754 }
755 </Box>
756 )
757 })
758
759 // Status badge is updated in the clock.every callback (turn.start already starts it)
760}
761hooks/dock-logic.ts 165 lines1// dock-logic.ts — pure functions, zero $ calls, safe to unit-test directly
2
3export function parseSize(s: string): number | null {
4 const trimmed = s.trim()
5 if (!/^\d+$/.test(trimmed)) return null
6 const n = parseInt(trimmed, 10)
7 if (n < 1 || n > 100) return null
8 return n
9}
10
11export function jobName(text: string): string {
12 const cleaned = text.replace(/\s+/g, ' ').trim()
13 if (cleaned.length <= 42) return cleaned
14 const cut = cleaned.slice(0, 42)
15 const sp = cut.lastIndexOf(' ')
16 return (sp > 10 ? cut.slice(0, sp) : cut) + '…'
17}
18
19export function initials(description: string): string {
20 const words = description.trim().split(/\s+/).filter(Boolean)
21 if (words.length === 0) return 'AG'
22 if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase()
23 return (words[0]![0]! + words[1]![0]!).toUpperCase()
24}
25
26const COLORS = ['#e07b54', '#d4a855', '#6ab187', '#5b9ed6', '#9b7ec8', '#d66b8a', '#5fada0', '#a0843a']
27export function colorFor(s: string): string {
28 let h = 0
29 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
30 return COLORS[h % COLORS.length]!
31}
32
33export function splitInstruction(n: number, helperModel: 'haiku' | 'same'): string {
34 const modelNote = helperModel === 'haiku'
35 ? 'Helper agents will run on a fast cheap model.'
36 : ''
37 return `## Agent Dock — split this request across exactly ${n} helpers
38
39Rules (follow precisely):
40- Launch exactly ${n} Agent tool calls in ONE message so they run in parallel.
41- Find a real split: one helper per item, file, section, store, task or subtopic.
42- Give each helper a short plain-English description of 3–5 words, e.g. "Price check: Panera".
43- In each helper's prompt include: "As you work, call report_progress with your task name and a percent at about 25, 50, 75 and 100. Do not call plan_steps."
44- When all helpers finish, combine their results into one answer.
45- Never argue the request cannot be split. Never pad with useless work.
46${modelNote}`.trim()
47}
48
49export function nudgeText(used: number, total: number): string {
50 const remaining = total - used
51 return `You used ${used} of ${total} helpers. Split the remaining work across the other ${remaining}, one helper per piece, all in parallel.`
52}
53
54export function badgeText(working: number, queued: number, done: number): string {
55 const parts: string[] = []
56 if (working > 0) parts.push(`${working} working`)
57 if (queued > 0) parts.push(`${queued} queued`)
58 if (done > 0) parts.push(`${done} done`)
59 return parts.length > 0 ? `◆ ${parts.join(' · ')}` : '◆ Dock'
60}
61
62export function summaryLine(n: number, job: string, ms: number, stuck: number): string {
63 const t = fmtTime(ms)
64 const stuckNote = stuck > 0 ? ` (${stuck} got stuck)` : ''
65 return `✓ ${n} agent${n !== 1 ? 's' : ''} finished "${job}" in ${t}${stuckNote}`
66}
67
68export function meterBar(pct: number, width = 18): string {
69 const filled = Math.round(Math.max(0, Math.min(100, pct)) * width / 100)
70 return '━'.repeat(filled) + '╌'.repeat(width - filled)
71}
72
73export function fmtTime(ms: number): string {
74 const s = Math.floor(ms / 1000)
75 if (s < 60) return `${s}s`
76 const m = Math.floor(s / 60)
77 const rem = s % 60
78 return rem > 0 ? `${m}m ${rem}s` : `${m}m`
79}
80
81export function fmtTimer(startMs: number | null, nowMs: number): string {
82 if (startMs === null) return '0:00'
83 const s = Math.floor((nowMs - startMs) / 1000)
84 const m = Math.floor(s / 60)
85 const sec = s % 60
86 return `${m}:${String(sec).padStart(2, '0')}`
87}
88
89export function overallPct(helpers: Array<{ pct: number; status: string }>): number {
90 if (helpers.length === 0) return 0
91 const sum = helpers.reduce((a, h) => a + (h.status === 'done' ? 100 : h.pct), 0)
92 return Math.round(sum / helpers.length)
93}
94
95export const ROLE_PRESETS = [
96 'Backend', 'Frontend', 'Tester', 'ML Engineer', 'DevOps',
97 'Data Analyst', 'Researcher', 'Writer', 'Architect', 'Security', 'Designer',
98]
99
100export function nextRole(current: string): string {
101 if (!current) return ROLE_PRESETS[0]!
102 const i = ROLE_PRESETS.indexOf(current)
103 if (i === -1 || i === ROLE_PRESETS.length - 1) return ''
104 return ROLE_PRESETS[i + 1]!
105}
106
107export function roleInitials(role: string): string {
108 const map: Record<string, string> = {
109 'Backend': 'BE', 'Frontend': 'FE', 'Tester': 'QA', 'ML Engineer': 'ML',
110 'DevOps': 'DV', 'Data Analyst': 'DA', 'Researcher': 'RE', 'Writer': 'WR',
111 'Architect': 'AR', 'Security': 'SC', 'Designer': 'DS',
112 }
113 return map[role] ?? initials(role)
114}
115
116export function splitInstructionWithRoles(
117 n: number,
118 roles: Array<{ name: string; instructions: string }>,
119 helperModel: 'haiku' | 'same',
120): string {
121 const hasRoles = roles.some(r => r.name)
122 if (!hasRoles) return splitInstruction(n, helperModel)
123 const modelNote = helperModel === 'haiku' ? '\nHelper agents will run on a fast cheap model.' : ''
124 const roleLines = roles.map((r, i) => {
125 const roleName = r.name || `Helper ${i + 1}`
126 const noteStr = r.instructions ? ` Special instructions: "${r.instructions}"` : ''
127 return ` - Agent ${i + 1} (${roleName}): approach from the ${roleName} perspective.${noteStr}`
128 }).join('\n')
129 return `## Agent Dock — split this request across exactly ${n} specialists
130
131Rules (follow precisely):
132- Launch exactly ${n} Agent tool calls in ONE message so they run in parallel.
133- Assign each agent its specialist role:
134${roleLines}
135- In each agent's prompt include: "As you work, call report_progress with your task name and a percent at about 25, 50, 75 and 100. Do not call plan_steps."
136- When all agents finish, combine their results into one unified answer.
137- Never argue the request cannot be split. Never pad with useless work.${modelNote}`.trim()
138}
139
140export function teamInstructionWithRoles(
141 n: number,
142 roles: Array<{ name: string; instructions: string }>,
143 helperModel: 'haiku' | 'same',
144): string {
145 const modelNote = helperModel === 'haiku' ? '\nHelper agents will run on a fast cheap model.' : ''
146 const steps = roles.slice(0, n).map((r, i) => {
147 const roleName = r.name || `Agent ${i + 1}`
148 const noteStr = r.instructions ? ` Special instructions: "${r.instructions}".` : ''
149 const context = i === 0
150 ? ''
151 : i === 1
152 ? ` Include the previous agent's full output as context in the prompt.`
153 : ` Include all previous agents' full outputs as context in the prompt.`
154 return ` ${i + 1}. Spawn Agent ${i + 1} (${roleName}).${noteStr}${context} Its prompt must include: "As you work, call report_progress with your task name and a percent at about 25, 50, 75 and 100. Do not call plan_steps."`
155 }).join('\n')
156 return `## Agent Dock — TEAM MODE: run ${n} specialists in sequence, each building on the last
157
158Rules (follow precisely):
159- Run agents ONE AT A TIME in this order — do NOT spawn in parallel:
160${steps}
161- After all ${n} agents finish, synthesize their combined outputs into one unified answer.
162- Each agent's output feeds into the next — this is a dependency chain, not parallel work.
163- Never argue the request cannot be chained. Never pad with useless work.${modelNote}`.trim()
164}
165types/index.d.ts 43 lines1export type HelperStatus = 'queued' | 'working' | 'done' | 'stuck'
2export type DockPhase = 'idle' | 'live' | 'done'
3
4export interface AgentRole {
5 name: string
6 instructions: string
7}
8
9export interface Helper {
10 id: string
11 description: string
12 initials: string
13 color: string
14 status: HelperStatus
15 pct: number
16 startMs: number | null
17 endMs: number | null
18}
19
20export interface DockState {
21 teamSize: number
22 helperModel: 'haiku' | 'same'
23 mode: 'parallel' | 'team'
24 job: string
25 helpers: Helper[]
26 phase: DockPhase
27 jobStartMs: number | null
28 jobEndMs: number | null
29 isFolded: boolean
30 pendingBigTeam: number | null
31 stuckCount: number
32 roles: AgentRole[]
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'agent-dock': {
38 dock: DockState
39 tick: number
40 }
41 }
42}
43