SLOPSHOPPER

agent-dock

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dock
│ ┃ agent-dock ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ A G E N T D O C K │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ─────────── ⎿ Read 6 lines │ ┃ T E A M S I Z E [ [1] ] [ 3 ] [ 5 ] ⏺ Update(src/auth.ts) │ ┃ Claude decides how many helpers ⎿ Added 2 lines, removed 1 line │ ┃ No keys? Ctrl+X then Tab to focus · 1 3 ⏺ Bash(bun test) │ ┃ 5 t w f c for size · m model · x mode ⎿ 3 pass, 1 fail │ ┃ Mode: [ ⚡ Parallel ✓ ] [ 🔗 Team ] │ ┃ Helpers: [ Fast & Cheap ✓ ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ────────────────────────────────────────── │ ┃ ─────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ◆ │ ┃ › /dock │ ┃ Your team of 1 is standing by ⎿ agent-dock: ◆ Dock │ ┃ │ ┃ Send a request and it splits across 1 │ ┃ helpers. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agent-dock
◆ A G E N T D O C K STANDING BY ───────────────────────────────────────────────────── T E A M S I Z E [ [1] ] [ 3 ] [ 5 ] [ 10 ] [ 20 Claude decides how many helpers No keys? Ctrl+X then Tab to focus · 1 3 5 t w f c for size · m model · x mode Mode: [ ⚡ Parallel ✓ ] [ 🔗 Team ] Helpers: [ Fast & Cheap ✓ ] ───────────────────────────────────────────────────── ◆ Your team of 1 is standing by Send a request and it splits across 1 helpers.
Source 4 files
hooks/register.tsx 7 lines
1import type { Register } from 'claude-code'
2import { registerDock } from './dock'
3
4export const register: Register = on => {
5  registerDock(on)
6}
7
hooks/dock.tsx 761 lines
1import { 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}
761
hooks/dock-logic.ts 165 lines
1// 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}
165
types/index.d.ts 43 lines
1export 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