Function-hook mods: Clean View, a calm checklist above the prompt instead of tool calls, diffs and command output, and the Agent Dock, a team of parallel…

Function-hook mods that change how Claude Code looks while it works:
They ship together because the dock's helpers report through Clean View's report_progress tool.
Claude Code 2.1.291 or newer. Mods use the function-hook plugin API, which is early access, so a later Claude Code release may change it.
claude plugin marketplace add rube-de/cc-skills
claude plugin install mods-toolbox@rube-cc-skills
Or at the prompt: /plugin install mods-toolbox --marketplace rube-de/cc-skills.
Coming from clean-view: this plugin used to be called clean-view. Run claude plugin uninstall clean-view@rube-cc-skills before installing it, or both copies register /simple, /dock and the tools twice. Clean View on/off, Team Size and helper model start back at their defaults.
A calm, friendly Claude Code for people who aren't technical. While Claude works, tool calls, file diffs and command output are hidden, and one checklist above the prompt shows the plan, what's happening now and how far along it is.
Build my landing page · 1m 12s [ ● Clean View: ON ]
✓ Read your brand notes ██████████ Done
▶ Build the pricing section ██████░░░░ 60%
○ Add the contact form ░░░░░░░░░░ Next
○ Polish the footer ░░░░░░░░░░ Up next
Claude's written replies stay visible. Permission prompts and questions stay visible too, and switch the header to Needs you.
Clean View starts on. Click [ ● Clean View: ON ] above the prompt, or type:
| Command | Effect |
|---|---|
/simple | Flip it |
/simple on | Turn it on |
/simple off | Turn it off: every hidden row comes back, only the button stays |
Both work while Claude is busy, and the choice is remembered after a restart.
| Area | Behaviour |
|---|---|
| Plan first | Claude is asked to lay out 2 to 8 plain-English steps (plan_steps) before anything else, and other tools are refused until it has. A to-do list (TodoWrite, TaskCreate) also counts as a plan. Subagents are never gated. Off while Clean View is off. |
| Progress | Claude reports progress per step (report_progress); 100% checks a step off and starts the next. While Clean View is off, both tools stay registered but Claude's own calls change nothing, and plan_steps waits behind ToolSearch. report_progress stays in front because helpers' reports still reach the Agent Dock. |
| Job name | While Clean View is on, each new request sends one small Haiku call, at low effort, to name the job in 2 to 6 words. |
| Hidden rows | ToolUse, ToolResult and ToolGroup rows, and the "run in background" hint. |
| States | Working, Needs you (permission prompt, question, waiting for your reply), Stuck (you said no, repeated failures, API errors in one plain sentence), Stopped (Esc), All done (shrinks to one line after 5 seconds). |
Pick a Team Size and every request you send is split across exactly that many helper agents running in parallel, each shown as its own card in a pane called Agent Dock.
◆ A G E N T D O C K ● L I V E
────────────────────────────────────────────────────────────────────────────
T E A M S I Z E ╭ 1 3 5 10 20 [50] 100 │ Custom ╮
Splits each request across 50 helpers · 20 at a time · Fast & Cheap
M I S S I O N Research bakery pricing 45% 1:12
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
● 20 working ○ 10 queued ✓ 20 done ✕ 0 stuck
| Command | Effect |
|---|---|
/dock | Open the dock, or fold it to a badge under the prompt (20 working · 10 queued · 20 done) |
/dock 10 | Set the Team Size (1 to 100) and open the dock |
| ◆ Dock | The button at the right of the prompt footer opens it too |
CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS (default 20) instead of queueing it. The dock holds those calls until a slot frees up. "At a time" is the lower of that limit and CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY (default 10). Set both to 20 in the env block of your user settings for waves of 20.report_progress at about 25, 50, 75 and 100%, and each card's meter follows its own helper.Status line count: run /mods-toolbox:dock-setup to add ◇ N agents to your status line, either globally or for one project only. While helpers run, the dock writes the live count to ~/.claude/agent-dock/agents-now/<session id>.json (under $CLAUDE_CONFIG_DIR when set) and removes the file when they finish or the session ends. The skill installs a small add-on, ~/.claude/agent-dock/statusline.sh, that wraps your own status line command and adds the segment to its first line, so your script stays untouched. It also sets the status line to refresh every 2 seconds (refreshInterval), so the count keeps moving while Claude waits for helpers. /mods-toolbox:dock-setup uninstall undoes it in every project the skill touched (it keeps a list), puts each status line back as it was, removes any status line block or settings file the setup itself created, and deletes the add-on.
Every face is a colored two-letter badge. Past 12 helpers the cards shrink to one-line tiles so 50 or 100 still fit. The animation clock runs only while a request is live, so the idle seats are a still row with no twinkle.
Cost: at size 50 every request becomes 50 agents. Keep the size at 1 unless you mean it.
The checklist lives in $.state under the mods-toolbox key, typed in types/index.d.ts: cleanViewEnabled, checklist and tick, and the dock's dockTeamSize, dockMission and the rest beside them. List mods-toolbox under dependencies in your mod's plugin.json to get the types laid beside it.
claude --plugin-dir plugins/mods-toolbox # run it from this checkout
claude plugin validate plugins/mods-toolbox
claude plugin test plugins/mods-toolbox
hooks/register.tsx is the entry point: it calls registerDock(on) from hooks/dock.tsx first, so the dock's hook on a helper's report_progress runs before Clean View's, then registerCleanView(on) from hooks/clean-view.tsx. Every step name goes through hooks/clean-name.ts, and both mods take percents and durations from hooks/progress.ts. The dock's logic that needs no $ (sizes, names, instruction texts, counts, meters) lives in hooks/dock-logic.ts so tests call it directly. Claude Code writes the API types into .claude-plugin/types/ (gitignored) when it loads the plugin, and tsconfig.json extends them, so tsc -p plugins/mods-toolbox works after one load.
hooks/register.tsx 12 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4import { registerDock } from './dock'
5
6// The dock goes first: registrations nest first-outermost, so its hook on a
7// helper's report_progress runs before Clean View's catch-all answers it.
8export const register: Register = on => {
9 registerDock(on)
10 registerCleanView(on)
11}
12hooks/clean-view.tsx 821 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 On,
5 RenderInput,
6 RenderNode,
7 Timer,
8 ToolCallInput,
9 ToolCallResult,
10 TurnCompleteInput,
11} from 'claude-code'
12
13import type { CleanViewChecklist, CleanViewPhase, CleanViewTask } from '../types'
14import { MAX_NAME_LENGTH, cleanName } from './clean-name'
15import { clampPercent, formatDuration } from './progress'
16
17type Engine = EngineInterface
18type Checklist = CleanViewChecklist
19
20const enabledAtom = atom({ plugin: 'mods-toolbox', key: 'cleanViewEnabled' } as const, true)
21const checklistAtom = atom({ plugin: 'mods-toolbox', key: 'checklist' } as const, null)
22const tickAtom = atom({ plugin: 'mods-toolbox', key: 'tick' } as const, 0)
23
24const STORE_KEY = 'cleanViewEnabled'
25const ALWAYS_ALLOWED = new Set([
26 'ToolSearch',
27 'TodoWrite',
28 'TaskCreate',
29 'TaskUpdate',
30 'AskUserQuestion',
31])
32
33const FRAME_MS = 250
34const COLLAPSE_AFTER_MS = 5000
35const METER_CELLS = 10
36const FAILURES_BEFORE_STUCK = 3
37const MAX_STEPS = 8
38
39const DEFAULT_TITLE = 'Working on your request'
40const NEEDS_OK = 'Claude needs your OK to continue'
41const HAS_QUESTION = 'Claude has a question for you'
42const WAITING_FOR_REPLY = 'Claude is waiting for your reply'
43const SAID_NO = 'you said no to a step, so Claude paused'
44const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
45const REFUSED = "Claude couldn't help with that request"
46const API_TROUBLE = 'something went wrong talking to Claude, try again'
47const OFF_NOTE = 'Clean View is off, so there is no checklist to update. Carry on without it.'
48
49// The words Claude Code hands the model when the person rejects a permission prompt.
50const PERSON_SAID_NO = /doesn't want to proceed|tool use was rejected/i
51
52function gateMessage(): string {
53 return (
54 `Clean View: call ${tools.plan} first to lay out the steps of this request ` +
55 `(if it is deferred, load it with ToolSearch "select:${tools.plan},${tools.progress}"), then try again.`
56 )
57}
58
59function guide(): string {
60 return [
61 '# Clean View',
62 'The person sees a short checklist of your steps instead of your tool calls, so keep it accurate and friendly.',
63 `- For every request, even a quick question, call ${tools.plan} first with every step of the job, 2 to 8 in order. If it is deferred, load it with ToolSearch ("select:${tools.plan},${tools.progress}"). Other tools are refused until a plan exists. If this session has TodoWrite or TaskCreate, your to-do list can serve as the plan instead.`,
64 `- Call ${tools.progress} with the step's name and a percent as real progress happens, and with 100 the moment a step is finished.`,
65 '- Write every step name in plain English a non-technical person understands: under 40 characters, starting with a verb, like "Build the pricing section".',
66 '- Never put file paths, file names, commands, code or tool names in a step name.',
67 ].join('\n')
68}
69
70const TITLE_PROMPT =
71 'Name this request in 2 to 6 plain words a non-technical person understands, starting with a verb, ' +
72 'like "Build my landing page". No file names, code or punctuation. Reply with the name only.\n\nRequest:\n'
73
74// Timers and per-turn flags live with the module: a reload drops them and
75// session.start puts the timers back from the checklist.
76// The engine names the two tools when it registers them (mcp__<plugin>__<name>);
77// these defaults hold until it has.
78let tools = { plan: 'mcp__mods-toolbox__plan_steps', progress: 'mcp__mods-toolbox__report_progress' }
79let ticker: Timer | null = null
80let collapseTimer: Timer | null = null
81let areToolsReady = false
82let isExpansionPending = false
83let lastApiTrouble: string | null = null
84
85// The dock tells helpers to call these by the names the engine gave them.
86export function cleanViewTools(): { plan: string; progress: string } {
87 return tools
88}
89
90export function registerCleanView(on: On) {
91 on('session.start', async ($, e, next) => {
92 const saved = await $.store.get(STORE_KEY)
93 await update($, enabledAtom, () => saved !== false)
94
95 const job = await read($, checklistAtom)
96 syncTicker($, job)
97 scheduleCollapse($, job, await $.clock.now())
98
99 await $.command.register({
100 name: 'simple',
101 description: 'Turn Clean View on or off',
102 argumentHint: 'on|off',
103 immediate: true,
104 })
105 // The plan-first gate only stands once Claude can call plan_steps.
106 areToolsReady = await registerTools($).then(
107 names => {
108 tools = names
109 return true
110 },
111 () => false,
112 )
113
114 return next(e)
115 })
116
117 on('command.run', { command: 'simple' }, async ($, e) => {
118 const wanted = e.args.trim().toLowerCase()
119 if (wanted !== '' && wanted !== 'on' && wanted !== 'off') {
120 return { text: 'Type /simple on, /simple off, or just /simple to switch.' }
121 }
122 const isOn = await setEnabled($, wanted === '' ? 'toggle' : wanted === 'on')
123
124 return { text: isOn ? 'Clean View is on.' : 'Clean View is off.' }
125 })
126
127 // While off, plan_steps waits behind ToolSearch so its "before anything else" doesn't nudge Claude.
128 // report_progress stays in front: the dock's helpers call it whether Clean View is on or not.
129 on('tool.describe', async ($, e, next) => {
130 const described = await next(e)
131 const tool = String(e.tool)
132 if (!isOwnTool(tool)) {
133 return described
134 }
135
136 return { ...described, isDeferred: tool === tools.plan && !(await read($, enabledAtom)) }
137 })
138
139 on('prompt.compose', async ($, e, next) => {
140 const composed = await next(e)
141 if (!(await read($, enabledAtom))) {
142 return composed
143 }
144
145 return { sections: [...composed.sections, { id: 'clean-view:guide', text: guide(), scope: 'session' }] }
146 })
147
148 on('classic.UserPromptExpansion', ($, e, next) => {
149 isExpansionPending = true
150
151 return next(e)
152 })
153
154 on('turn.start', async ($, e, next) => {
155 const isCommand = isExpansionPending || e.text.trimStart().startsWith('/')
156 isExpansionPending = false
157 lastApiTrouble = null
158 if (isCommand || e.text.trim() === '') {
159 return next(e)
160 }
161 // While off there is no job to show, so no checklist to keep and no title to ask Haiku for.
162 if (!(await read($, enabledAtom))) {
163 await change($, () => null)
164 return next(e)
165 }
166
167 const now = await $.clock.now()
168 const job = await change($, current =>
169 current !== null && isWaitingOnPerson(current)
170 ? { ...working(current), turnId: e.turnId, failedInARow: 0, finishedAt: null, isCollapsed: false }
171 : newJob(e.turnId, e.turnId, now),
172 )
173 if (job?.jobId === e.turnId) {
174 nameJob($, e.turnId, e.text)
175 }
176
177 return next(e)
178 })
179
180 on('tool.call', async ($, e, next) => {
181 const tool = String(e.tool)
182 if (isOwnTool(tool)) {
183 // While off there is no checklist, so a stray call has nothing to update.
184 if (!(await read($, enabledAtom))) {
185 return { result: OFF_NOTE }
186 }
187 return tool === tools.plan ? planSteps($, e) : reportProgress($, e)
188 }
189 if (e.agentId !== undefined) {
190 return next(e)
191 }
192
193 const job = await read($, checklistAtom)
194 const needsPlan = areToolsReady && isTurnRunning(job) && job.planSource === null
195 if (needsPlan && !ALWAYS_ALLOWED.has(tool) && (await read($, enabledAtom))) {
196 return { deny: gateMessage() }
197 }
198
199 await change($, current => {
200 if (!isTurnRunning(current)) {
201 return current
202 }
203 if (tool === 'AskUserQuestion') {
204 return { ...current, phase: 'needsYou', needsYouReason: HAS_QUESTION, stuckReason: null }
205 }
206
207 return current.phase === 'needsYou' ? working(current) : current
208 })
209
210 const ran = await next(e)
211 if (ran.deny === undefined) {
212 await change($, current => (isTurnRunning(current) ? afterTool(current, e, ran) : current))
213 }
214
215 return ran
216 })
217
218 on('classic.Notification', async ($, e, next) => {
219 const kind = e.notification_type
220 const reason = /permission/i.test(kind) ? NEEDS_OK : /elicitation|question/i.test(kind) ? HAS_QUESTION : null
221 if (reason !== null) {
222 await needsYou($, reason)
223 }
224
225 return next(e)
226 })
227
228 on('classic.PermissionRequest', async ($, e, next) => {
229 await needsYou($, NEEDS_OK)
230
231 return next(e)
232 })
233
234 on('classic.StopFailure', async ($, e, next) => {
235 if (e.agent_id === undefined) {
236 const trouble = describeApiTrouble(e.error, e.error_details ?? '')
237 lastApiTrouble = trouble
238 // Whichever of this and turn.complete lands first, the plain reason wins.
239 await change($, job =>
240 job?.phase === 'stuck' && job.stuckReason === API_TROUBLE ? { ...job, stuckReason: trouble } : job,
241 )
242 }
243
244 return next(e)
245 })
246
247 on('turn.complete', async ($, e, next) => {
248 if (e.agentId !== undefined) {
249 return next(e)
250 }
251 const now = await $.clock.now()
252 const trouble = lastApiTrouble
253 const job = await change($, current => (isTurnRunning(current) ? finished(current, e, now, trouble) : current))
254 scheduleCollapse($, job, now)
255
256 return next(e)
257 })
258
259 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
260 if (!(await read($, enabledAtom))) {
261 return next(e)
262 }
263 const { Box } = $.ui.resolve(e)
264
265 return <Box display="none" />
266 })
267
268 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
269 if (!(await read($, enabledAtom))) {
270 return next(e)
271 }
272 const { Box } = $.ui.resolve(e)
273
274 return <Box display="none" />
275 })
276
277 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
278 if (!(await read($, enabledAtom))) {
279 return next(e)
280 }
281 const { Box } = $.ui.resolve(e)
282
283 return <Box display="none" />
284 })
285
286 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
287 (await read($, enabledAtom)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
288 )
289
290 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291 if (e.props.hasSurvey) {
292 return next(e)
293 }
294 // The band is shared with other mods: Clean View sits on top of whatever the hooks beneath draw.
295 const { Box } = $.ui.resolve(e)
296 const band = await drawBand($, e)
297 const below = await next(e)
298
299 return (
300 <Box flexDirection="column">
301 {band}
302 {below}
303 </Box>
304 )
305 })
306}
307
308async function drawBand($: Engine, e: RenderInput<'AbovePrompt'>) {
309 const { Box, Text, Button } = $.ui.resolve(e)
310 const isEnabled = await read($, enabledAtom)
311 const job = isEnabled ? await read($, checklistAtom) : null
312 const label = isEnabled ? '● Clean View: ON' : '○ Clean View: OFF'
313 const toggle = <Button key="toggle" label={label} onPress={() => setEnabled($, 'toggle')} />
314
315 if (job === null) {
316 return (
317 <Box flexDirection="row" justifyContent="flex-end">
318 {toggle}
319 </Box>
320 )
321 }
322
323 const frame = isAnimated(job.phase) ? await read($, tickAtom) : 0
324 const now = await $.clock.now()
325 const columns = e.props.bodyColumns
326 const header = (
327 <Box flexDirection="row" justifyContent="space-between">
328 <Box width={Math.max(1, columns - label.length - 5)}>
329 <Text wrap="truncate-end">{headline(Text, job, now)}</Text>
330 </Box>
331 {toggle}
332 </Box>
333 )
334 if (job.isCollapsed) {
335 return header
336 }
337
338 // mark (2) + name + gap (1) + meter (10) + gap (2) + label (7)
339 const nameWidth = Math.min(MAX_NAME_LENGTH + 1, Math.max(6, columns - 22))
340 const firstUpcoming = job.tasks.findIndex(task => task.status === 'upcoming')
341
342 return (
343 <Box flexDirection="column">
344 {header}
345 {job.tasks.map((task, index) => (
346 <Box key={`row-${task.id}`} flexDirection="row">
347 <Box width={2}>{mark(Text, task, job.phase)}</Box>
348 <Box width={nameWidth}>
349 <Text wrap="truncate-end" bold={task.status === 'active'} dimColor={task.status !== 'active'}>
350 {task.name}
351 </Text>
352 </Box>
353 <Text> </Text>
354 {meter(Text, task, job.phase, frame)}
355 <Text> </Text>
356 <Text dimColor={task.status !== 'active'}>{statusLabel(task, index === firstUpcoming)}</Text>
357 </Box>
358 ))}
359 </Box>
360 )
361}
362
363async function registerTools($: Engine) {
364 const plan = await $.tool.register({
365 name: 'plan_steps',
366 description:
367 'Lay out every step of the current job before doing anything else: 2 to 8 short names in order, ' +
368 'in plain English, each under 40 characters and starting with a verb. No file names, paths, commands or code. ' +
369 'The first step starts right away.',
370 inputSchema: {
371 type: 'object',
372 properties: {
373 steps: {
374 type: 'array',
375 items: { type: 'string' },
376 minItems: 2,
377 maxItems: MAX_STEPS,
378 description: 'The steps in order, like "Build the pricing section"',
379 },
380 },
381 required: ['steps'],
382 additionalProperties: false,
383 },
384 })
385 const progress = await $.tool.register({
386 name: 'report_progress',
387 description:
388 'Report progress on a step of the plan: its name as planned and a percent from 0 to 100. ' +
389 'Report 100 the moment a step is finished; that checks it off and starts the next one.',
390 inputSchema: {
391 type: 'object',
392 properties: {
393 task: { type: 'string', description: 'The step name, as planned' },
394 percent: { type: 'number', minimum: 0, maximum: 100 },
395 },
396 required: ['task', 'percent'],
397 additionalProperties: false,
398 },
399 })
400
401 return { plan: plan.tool, progress: progress.tool }
402}
403
404async function planSteps($: Engine, e: ToolCallInput): Promise<ToolCallResult> {
405 const steps = stringList(argsOf(e).steps).slice(0, MAX_STEPS).map(cleanName)
406 if (steps.length < 2) {
407 return { deny: 'plan_steps needs "steps": a list of 2 to 8 short step names.' }
408 }
409 const answer = { result: `Planned ${steps.length} steps. The first one has started.` }
410 // A subagent's plan is its own business; the checklist follows the main agent.
411 if (e.agentId !== undefined) {
412 return answer
413 }
414
415 const now = await $.clock.now()
416 const job = await change($, current => {
417 // No job yet (a slash command's turn, say): start one; turn.complete still ends it.
418 const base = isTurnRunning(current) ? current : newJob(`plan-${now}`, 'untracked', now)
419
420 return {
421 ...working(base),
422 planSource: 'steps',
423 tasks: steps.map((name, index) => newTask(`step-${index + 1}`, name, index === 0 ? 'active' : 'upcoming')),
424 }
425 })
426 if (job?.jobId === `plan-${now}`) {
427 nameJob($, job.jobId, steps.join('; '))
428 }
429
430 return answer
431}
432
433async function reportProgress($: Engine, e: ToolCallInput): Promise<ToolCallResult> {
434 const args = argsOf(e)
435 const percent = clampPercent(args.percent)
436 if (e.agentId === undefined && typeof args.task === 'string') {
437 const name = args.task
438 await change($, current => (isTurnRunning(current) ? progressed(current, name, percent) : current))
439 }
440
441 return { result: `Progress noted: ${percent}%.` }
442}
443
444async function setEnabled($: Engine, wanted: boolean | 'toggle'): Promise<boolean> {
445 const wasOn = await read($, enabledAtom)
446 const isOn = await update($, enabledAtom, current => (wanted === 'toggle' ? !current : wanted))
447 // The engine keeps tool.describe's answer for the session; asking again moves plan_steps.
448 // Only on a real change, since each ask spends the prompt cache.
449 if (isOn !== wasOn) {
450 $.ui.invalidate('tool.describe')
451 }
452 await $.store.set(STORE_KEY, isOn)
453 $.ui.toast(isOn ? 'Clean View is on: you see the plan, not the details' : 'Clean View is off: every detail is showing')
454
455 return isOn
456}
457
458async function needsYou($: Engine, reason: string) {
459 await change($, job =>
460 isTurnRunning(job) ? { ...job, phase: 'needsYou', needsYouReason: reason, stuckReason: null } : job,
461 )
462}
463
464async function change($: Engine, fn: (job: Checklist | null) => Checklist | null): Promise<Checklist | null> {
465 const job = await update($, checklistAtom, fn)
466 syncTicker($, job)
467
468 return job
469}
470
471function syncTicker($: Engine, job: Checklist | null) {
472 const shouldTick = job !== null && isAnimated(job.phase)
473 if (shouldTick && ticker === null) {
474 ticker = $.clock.every(FRAME_MS, () => {
475 void update($, tickAtom, frame => frame + 1)
476 })
477 } else if (!shouldTick && ticker !== null) {
478 ticker.cancel()
479 ticker = null
480 }
481}
482
483function scheduleCollapse($: Engine, job: Checklist | null, now: number) {
484 collapseTimer?.cancel()
485 collapseTimer = null
486 if (job === null || job.phase !== 'done' || job.isCollapsed || job.finishedAt === null) {
487 return
488 }
489 const { jobId } = job
490 const wait = Math.max(1, job.finishedAt + COLLAPSE_AFTER_MS - now)
491 collapseTimer = $.clock.after(wait, () => {
492 void update($, checklistAtom, current =>
493 current?.jobId === jobId && current.phase === 'done' ? { ...current, isCollapsed: true } : current,
494 )
495 })
496}
497
498// Started from a timer so the request is not tied to the turn.start dispatch.
499function nameJob($: Engine, jobId: string, request: string) {
500 $.clock.after(1, () => {
501 void askForTitle($, jobId, request)
502 })
503}
504
505async function askForTitle($: Engine, jobId: string, request: string) {
506 try {
507 const reply = await $.model.complete({
508 model: 'haiku',
509 effort: 'low',
510 maxTokens: 30,
511 timeoutMs: 20_000,
512 prompt: TITLE_PROMPT + request.slice(0, 2000),
513 })
514 if (!reply.isAnswered) {
515 return
516 }
517 const words = reply.text.replace(/[^\p{L}\p{N}\s'’-]/gu, ' ').trim().split(/\s+/).filter(Boolean)
518 if (words.length < 2) {
519 return
520 }
521 const title = cleanName(words.slice(0, 6).join(' '))
522 await update($, checklistAtom, job => (job?.jobId === jobId ? { ...job, title } : job))
523 } catch {
524 // The placeholder title stays; naming is a nicety.
525 }
526}
527
528function newJob(jobId: string, turnId: string, now: number): Checklist {
529 return {
530 jobId,
531 title: DEFAULT_TITLE,
532 phase: 'working',
533 tasks: [newTask('placeholder-1', 'Understand your request', 'active'), newTask('placeholder-2', 'Plan the steps', 'upcoming')],
534 planSource: null,
535 needsYouReason: null,
536 stuckReason: null,
537 failedInARow: 0,
538 turnId,
539 startedAt: now,
540 finishedAt: null,
541 isCollapsed: false,
542 }
543}
544
545function newTask(id: string, name: string, status: CleanViewTask['status']): CleanViewTask {
546 return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
547}
548
549function working(job: Checklist): Checklist {
550 return { ...job, phase: 'working', needsYouReason: null, stuckReason: null }
551}
552
553function progressed(job: Checklist, rawName: string, percent: number): Checklist {
554 const name = cleanName(rawName)
555 const tasks = job.planSource === null ? [] : job.tasks
556 let index = tasks.findIndex(task => task.name.toLowerCase() === name.toLowerCase())
557 let list = tasks
558 if (index < 0) {
559 const active = tasks.findIndex(task => task.status === 'active')
560 index = active < 0 ? tasks.length : active
561 list = [...tasks.slice(0, index), newTask(`added-${tasks.length + 1}`, name, 'upcoming'), ...tasks.slice(index)]
562 }
563 const isFinished = percent >= 100
564 const reported = list.map((task, at): CleanViewTask => {
565 if (at < index) {
566 return { ...task, status: 'done', percent: 100 }
567 }
568 if (at === index) {
569 return { ...task, status: isFinished ? 'done' : 'active', percent, hasReported: true }
570 }
571
572 return task.status === 'active' ? { ...task, status: 'upcoming' } : task
573 })
574
575 return { ...working(job), tasks: withOneActive(reported) }
576}
577
578function afterTool(job: Checklist, e: ToolCallInput, ran: ToolCallResult): Checklist {
579 if (ran.isError === true) {
580 if (PERSON_SAID_NO.test(ran.text ?? '')) {
581 return { ...job, phase: 'stuck', stuckReason: SAID_NO, needsYouReason: null, failedInARow: 0 }
582 }
583 const failedInARow = job.failedInARow + 1
584 if (failedInARow >= FAILURES_BEFORE_STUCK) {
585 return { ...job, failedInARow, phase: 'stuck', stuckReason: KEEPS_FAILING, needsYouReason: null }
586 }
587
588 return { ...(job.phase === 'needsYou' ? working(job) : job), failedInARow }
589 }
590
591 // A success clears both Stuck and Needs you.
592 const next = { ...working(job), failedInARow: 0 }
593 if (e.tool === 'TodoWrite') {
594 return fromTodos(next, e.todos)
595 }
596 if (e.tool === 'TaskCreate') {
597 const id = createdTaskId(ran.result)
598
599 return id === null ? next : withCreatedTask(next, id, e.subject)
600 }
601 if (e.tool === 'TaskUpdate') {
602 return withUpdatedTask(next, e.taskId, e.status, e.subject)
603 }
604
605 return next
606}
607
608const TODO_STATUS = { completed: 'done', in_progress: 'active', pending: 'upcoming' } as const
609
610function fromTodos(
611 job: Checklist,
612 todos: ReadonlyArray<{ content: string; status: keyof typeof TODO_STATUS }>,
613): Checklist {
614 const tasks = todos.map((todo, index) => {
615 const id = `todo-${index + 1}`
616 const name = cleanName(todo.content)
617 const status = TODO_STATUS[todo.status] ?? 'upcoming'
618 const before = job.tasks.find(task => task.name === name && task.status === 'active')
619
620 return status === 'active' && before !== undefined ? { ...before, id } : newTask(id, name, status)
621 })
622
623 return { ...job, planSource: 'todos', tasks: withOneActive(tasks) }
624}
625
626function withCreatedTask(job: Checklist, id: string, subject: string): Checklist {
627 const tasks = job.planSource === 'tasks' ? job.tasks : []
628
629 return { ...job, planSource: 'tasks', tasks: withOneActive([...tasks, newTask(`task-${id}`, cleanName(subject), 'upcoming')]) }
630}
631
632function withUpdatedTask(
633 job: Checklist,
634 taskId: string,
635 status: keyof typeof TODO_STATUS | 'deleted' | undefined,
636 subject: string | undefined,
637): Checklist {
638 const id = `task-${taskId}`
639 if (job.planSource !== 'tasks' || !job.tasks.some(task => task.id === id)) {
640 return job
641 }
642 if (status === 'deleted') {
643 return { ...job, tasks: withOneActive(job.tasks.filter(task => task.id !== id)) }
644 }
645 const next = status === undefined ? undefined : TODO_STATUS[status]
646 const tasks = job.tasks.map((task): CleanViewTask => {
647 if (task.id !== id) {
648 return next === 'active' && task.status === 'active' ? { ...task, status: 'upcoming' } : task
649 }
650 const renamed = subject === undefined ? task : { ...task, name: cleanName(subject) }
651
652 return next === undefined ? renamed : { ...renamed, status: next, percent: next === 'done' ? 100 : renamed.percent }
653 })
654
655 return { ...job, tasks: withOneActive(tasks) }
656}
657
658function withOneActive(tasks: CleanViewTask[]): CleanViewTask[] {
659 if (tasks.some(task => task.status === 'active')) {
660 return tasks
661 }
662 const next = tasks.findIndex(task => task.status === 'upcoming')
663
664 return next < 0 ? tasks : tasks.map((task, index) => (index === next ? { ...task, status: 'active' } : task))
665}
666
667function finished(job: Checklist, e: TurnCompleteInput, now: number, trouble: string | null): Checklist {
668 const ended: Checklist = { ...job, turnId: null, needsYouReason: null }
669 if (e.reason === 'error') {
670 return { ...ended, phase: 'stuck', stuckReason: trouble ?? API_TROUBLE }
671 }
672 if (e.reason === 'refusal') {
673 return { ...ended, phase: 'stuck', stuckReason: REFUSED }
674 }
675 // Saying no to a permission prompt also ends the turn; the no is the news.
676 if (job.phase === 'stuck' && job.stuckReason === SAID_NO) {
677 return ended
678 }
679 if (e.reason === 'aborted') {
680 return { ...ended, phase: 'stopped', stuckReason: null, finishedAt: now }
681 }
682 if (job.planSource !== null && job.tasks.some(task => task.status !== 'done')) {
683 return { ...ended, phase: 'needsYou', stuckReason: null, needsYouReason: WAITING_FOR_REPLY }
684 }
685
686 return {
687 ...ended,
688 phase: 'done',
689 stuckReason: null,
690 finishedAt: now,
691 tasks: job.tasks.map(task => ({ ...task, status: 'done', percent: 100 })),
692 }
693}
694
695function describeApiTrouble(kind: string, details: string): string {
696 if (/too long|context window|too many tokens/i.test(details)) {
697 return 'this chat got too long, type /compact and try again'
698 }
699 if (/network|connection|socket|econn|enotfound|fetch failed|offline/i.test(details)) {
700 return 'the internet connection dropped'
701 }
702 switch (kind) {
703 case 'rate_limit':
704 return 'you hit your usage limit, try again a little later'
705 case 'overloaded':
706 case 'server_error':
707 return "Claude's servers are busy, try again in a minute"
708 case 'authentication_failed':
709 case 'oauth_org_not_allowed':
710 case 'cloud_credential_error':
711 return "you're signed out, type /login"
712 case 'billing_error':
713 case 'account_on_hold':
714 case 'verification_required':
715 return 'your account needs attention, check your plan'
716 default:
717 return API_TROUBLE
718 }
719}
720
721function isOwnTool(tool: string): boolean {
722 return tool === tools.plan || tool === tools.progress
723}
724
725function isTurnRunning(job: Checklist | null): job is Checklist {
726 return job !== null && job.turnId !== null
727}
728
729function isWaitingOnPerson(job: Checklist): boolean {
730 const isPaused = job.phase === 'needsYou' || job.phase === 'stuck'
731
732 return isPaused && job.planSource !== null && job.tasks.some(task => task.status !== 'done')
733}
734
735function isAnimated(phase: CleanViewPhase): boolean {
736 return phase === 'working' || phase === 'needsYou'
737}
738
739function argsOf(e: ToolCallInput): Record<string, unknown> {
740 return e as unknown as Record<string, unknown>
741}
742
743function stringList(value: unknown): string[] {
744 return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '') : []
745}
746
747function createdTaskId(result: unknown): string | null {
748 const id = (result as { task?: { id?: unknown } } | null | undefined)?.task?.id
749
750 return typeof id === 'string' || typeof id === 'number' ? String(id) : null
751}
752
753type TextElement = ReturnType<Engine['ui']['resolve']>['Text']
754
755function headline(Text: TextElement, job: Checklist, now: number): RenderNode[] {
756 switch (job.phase) {
757 case 'working':
758 return [<Text bold>{job.title}</Text>, ` · ${formatDuration(now - job.startedAt)}`]
759 case 'needsYou':
760 return [
761 <Text backgroundColor="warning" color="inverseText" bold>
762 {' Needs you '}
763 </Text>,
764 ` ${job.needsYouReason ?? WAITING_FOR_REPLY}`,
765 ]
766 case 'stuck':
767 return [<Text color="warning">⚠ Stuck:</Text>, ` ${job.stuckReason ?? API_TROUBLE}`]
768 case 'stopped':
769 return [<Text color="warning">■ Stopped</Text>, ` · ${job.title} · you pressed Esc`]
770 case 'done':
771 return [
772 <Text color="success">✓ All done</Text>,
773 ` · ${job.title} · took ${formatDuration((job.finishedAt ?? now) - job.startedAt)}`,
774 ]
775 }
776}
777
778function mark(Text: TextElement, task: CleanViewTask, phase: CleanViewPhase) {
779 if (task.status === 'done') {
780 return <Text color="success">✓</Text>
781 }
782 if (task.status === 'upcoming') {
783 return <Text dimColor>○</Text>
784 }
785
786 return <Text bold>{phase === 'needsYou' ? '‖' : '▶'}</Text>
787}
788
789function meter(Text: TextElement, task: CleanViewTask, phase: CleanViewPhase, frame: number) {
790 if (task.status === 'done') {
791 return <Text color="success">{'█'.repeat(METER_CELLS)}</Text>
792 }
793 if (task.status === 'upcoming') {
794 return <Text dimColor>{'░'.repeat(METER_CELLS)}</Text>
795 }
796 if (task.hasReported || !isAnimated(phase)) {
797 const filled = Math.round(task.percent / 10)
798
799 return <Text>{'█'.repeat(filled) + '░'.repeat(METER_CELLS - filled)}</Text>
800 }
801 // No percent yet: a three-cell block sweeps across the meter.
802 const start = (frame % (METER_CELLS + 3)) - 3
803 let cells = ''
804 for (let cell = 0; cell < METER_CELLS; cell++) {
805 cells += cell >= start && cell < start + 3 ? '█' : '░'
806 }
807
808 return <Text>{cells}</Text>
809}
810
811function statusLabel(task: CleanViewTask, isNext: boolean): string {
812 if (task.status === 'done') {
813 return 'Done'
814 }
815 if (task.status === 'active') {
816 return task.hasReported ? `${task.percent}%` : 'Working'
817 }
818
819 return isNext ? 'Next' : 'Up next'
820}
821hooks/dock.tsx 950 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderInput, Timer, ToolCallResult } from 'claude-code'
3
4import type { DockCard, DockHelperModel, DockMission } from '../types'
5import { cleanViewTools } from './clean-view'
6import {
7 ACTIVE_STATUSES,
8 BADGE_COLORS,
9 BAD_CUSTOM,
10 CORAL,
11 GOLD,
12 GREEN,
13 HAIRLINE,
14 HELPERS_SKIP_PLAN,
15 INK,
16 MUTED,
17 RED,
18 SIZES,
19 SIZE_HELP,
20 STOPPED,
21 TILES_AFTER,
22 TOO_NARROW,
23 afterMainTurn,
24 atATime,
25 badgeText,
26 capMessage,
27 cardTime,
28 cardsPerRow,
29 closedOut,
30 confirmMessage,
31 countDir,
32 countFile,
33 countPath,
34 counts,
35 ended,
36 formatClock,
37 hasActiveHelper,
38 helperNote,
39 infoLine,
40 isBigTeam,
41 isFinished,
42 isLive,
43 jobName,
44 meter,
45 missionBar,
46 missionPercent,
47 mix,
48 newMission,
49 nudgeText,
50 parseSize,
51 reconciled,
52 reported,
53 restoreHelperModel,
54 restoreSize,
55 rows,
56 seats,
57 spaced,
58 standingBy,
59 started,
60 summary,
61 teamInstruction,
62 tilesPerRow,
63 withAgent,
64 withCard,
65 withQueued,
66 withoutCard,
67} from './dock-logic'
68import type { MeterPart } from './dock-logic'
69import { clampPercent } from './progress'
70
71type Engine = EngineInterface
72
73const PANE = 'agent-dock'
74const DOCK_COLUMNS = 78
75const FRAME_MS = 200
76const TILE_WIDTH = 17
77const SIZE_KEY = 'dock.teamSize'
78const MODEL_KEY = 'dock.helperModel'
79const FALLBACK_JOB = 'Helpers at work'
80// Prompts a person sent; a background task's notification is not a new request.
81const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
82
83const sizeRef = { plugin: 'mods-toolbox', key: 'dockTeamSize' } as const
84const sizeAtom = atom(sizeRef, 1)
85const modelAtom = atom({ plugin: 'mods-toolbox', key: 'dockHelperModel' } as const, 'fast')
86const pendingAtom = atom({ plugin: 'mods-toolbox', key: 'dockPendingSize' } as const, null)
87const customAtom = atom({ plugin: 'mods-toolbox', key: 'dockIsCustomOpen' } as const, false)
88const foldedAtom = atom({ plugin: 'mods-toolbox', key: 'dockIsFolded' } as const, false)
89const missionAtom = atom({ plugin: 'mods-toolbox', key: 'dockMission' } as const, null)
90const tickAtom = atom({ plugin: 'mods-toolbox', key: 'dockTick' } as const, 0)
91
92// Module state a reload may drop: session.start puts the clock and limits back.
93let ticker: Timer | null = null
94let atOnce = atATime(undefined, undefined)
95let lastJob: string | null = null
96let shownStatus: string | undefined | null = null
97let missionCount = 0
98// Agent calls let out of the hold whose subagent the engine has not listed yet.
99const launching = new Set<string>()
100// The live count the status line reads: where it goes, what was last written, in order.
101let countFolder: string | null = null
102let writtenPath: string | null = null
103let writtenKey: string | null = null
104let countQueue: Promise<void> = Promise.resolve()
105
106export function registerDock(on: On) {
107 on('session.start', { cwd: /^/ }, async ($, e, next) => {
108 try {
109 // A reload keeps this session's size; only a new session reads the store.
110 const { value: current } = await $.state.get(sizeRef)
111 if (current === undefined) {
112 const [savedSize, savedModel] = await Promise.all([$.store.get(SIZE_KEY), $.store.get(MODEL_KEY)])
113 await update($, sizeAtom, () => restoreSize(savedSize))
114 await update($, modelAtom, () => restoreHelperModel(savedModel))
115 }
116 atOnce = await readLimits($)
117 countFolder = await readCountFolder($)
118 await sweepCountFiles($)
119 syncTicker($, await read($, missionAtom))
120 await $.command.register({
121 name: 'dock',
122 description: 'Show or fold the Agent Dock, or set its Team Size (1 to 100)',
123 argumentHint: '[team size]',
124 immediate: true,
125 })
126 } catch {
127 // The dock stays at its defaults; Clean View carries on either way.
128 }
129
130 return next(e)
131 })
132
133 // No awaits here: the answer goes back at once and the pane opens after.
134 on('command.run', { command: 'dock' }, ($, e) => {
135 try {
136 const wanted = e.args.trim()
137 if (wanted === '') {
138 void toggleDock($)
139 return { text: 'Agent Dock toggled. Type /dock again to switch back.' }
140 }
141 const size = parseSize(wanted)
142 if (size === null) {
143 return { text: SIZE_HELP }
144 }
145 void (async () => {
146 try {
147 await chooseSize($, size)
148 await showDock($)
149 } catch {
150 $.ui.toast('The Agent Dock could not open. Try /dock again.')
151 }
152 })()
153
154 return { text: isBigTeam(size) ? confirmMessage(size) : `Team Size is ${size}.` }
155 } catch {
156 return { text: 'The Agent Dock could not answer. Try /dock again.' }
157 }
158 })
159
160 on('prompt.submit', async ($, e, next) => {
161 let context: string | null = null
162 try {
163 const text = e.text.trim()
164 const isRequest = PERSON_ORIGINS.has(e.origin.kind) && text !== '' && !text.startsWith('/')
165 let current = isRequest ? await read($, missionAtom) : null
166 // Idle with a mission still open and no helper of it running: its finish
167 // was missed, so close it rather than leave this request unsplit.
168 if (isLive(current) && e.turnId === undefined) {
169 const statuses = await agentStatuses($)
170 if (statuses !== null && !hasActiveHelper(current, statuses)) {
171 const now = await $.clock.now()
172 current = await change($, mission => (isLive(mission) ? closedOut(mission, statuses, now) : mission))
173 }
174 }
175 if (isRequest && !isLive(current)) {
176 const job = jobName(text)
177 lastJob = job
178 const size = await read($, sizeAtom)
179 const now = await $.clock.now()
180 // At size 1 Claude decides: a mission starts with its first helper.
181 const mission = size > 1 ? newMission(`mission-${now}-${++missionCount}`, job, size, now) : null
182 await change($, () => mission)
183 if (size > 1) {
184 const { progress, plan } = cleanViewTools()
185 context = teamInstruction(size, progress, plan)
186 }
187 }
188 } catch {
189 context = null
190 }
191
192 return context === null ? next(e) : next({ ...e, context: [...(e.context ?? []), context] })
193 })
194
195 // Cards show queued as soon as Claude writes the Agent calls.
196 on('session.append', { door: 'response' }, async ($, e, next) => {
197 if (e.agentId === undefined) {
198 try {
199 const calls = agentCalls(e.message.content)
200 if (calls.length > 0) {
201 const fresh = await missionFor($)
202 await change($, current => {
203 const mission = isLive(current) ? current : fresh
204 return withQueued(mission, calls, mission.size > 1 ? mission.size : null)
205 })
206 }
207 } catch {
208 // Cards still appear once the calls start.
209 }
210 }
211
212 return next(e)
213 })
214
215 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
216 if (e.agentId !== undefined) {
217 return next(e)
218 }
219 const id = e.tool_use_id
220 const admitted = await admit($, id, e.description).catch(() => ({ size: 1, deny: null }))
221 if (admitted.deny !== null) {
222 return { deny: admitted.deny }
223 }
224 if (admitted.size > 1) {
225 const isStopped = await waitForSlot($, id, next.signal).catch(() => false)
226 if (isStopped) {
227 await finish($, id, 'stuck').catch(() => undefined)
228 return { deny: STOPPED }
229 }
230 }
231 await startCard($, id).catch(() => undefined)
232
233 let ran: ToolCallResult
234 try {
235 ran = await next(e)
236 } finally {
237 launching.delete(id)
238 }
239 await afterCall($, id, ran).catch(() => undefined)
240
241 return ran
242 })
243
244 // Helpers report on their own card; Clean View's checklist follows the main agent.
245 on('tool.call', { tool: /__(report_progress|plan_steps)$/ }, async ($, e, next) => {
246 const agentId = e.agentId
247 const tools = cleanViewTools()
248 if (agentId === undefined || (e.tool !== tools.progress && e.tool !== tools.plan)) {
249 return next(e)
250 }
251 if (e.tool === tools.plan) {
252 return { result: HELPERS_SKIP_PLAN }
253 }
254 const percent = clampPercent((e as unknown as { percent?: unknown }).percent)
255 await change($, mission => (mission === null ? mission : withAgent(mission, agentId, card => reported(card, percent)))).catch(
256 () => undefined,
257 )
258
259 return { result: `Progress noted: ${percent}%.` }
260 })
261
262 on('agent.spawn', async ($, e, next) => {
263 if (e.parentAgentId !== undefined || e.workflow !== undefined) {
264 return next(e)
265 }
266 let input = e
267 try {
268 const mission = await read($, missionAtom)
269 const isHelper = mission !== null && mission.size > 1 && mission.cards.some(card => card.id === e.tool_use_id)
270 if (isHelper) {
271 const model = await read($, modelAtom)
272 const { progress, plan } = cleanViewTools()
273 input = {
274 ...e,
275 ...(model === 'fast' && e.model === undefined && !e.fork ? { model: 'haiku' } : {}),
276 ...(e.prompt.includes(progress) ? {} : { prompt: `${e.prompt}\n\n${helperNote(progress, plan)}` }),
277 }
278 }
279 } catch {
280 input = e
281 }
282
283 const spawned = await next(input)
284 launching.delete(e.tool_use_id)
285 const agentId = 'agentId' in spawned ? spawned.agentId : undefined
286 if (agentId !== undefined) {
287 await change($, mission =>
288 mission === null ? mission : withCard(mission, e.tool_use_id, card => ({ ...card, agentId, isBackground: e.background })),
289 ).catch(() => undefined)
290 }
291
292 return spawned
293 })
294
295 on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
296 try {
297 const now = await $.clock.now()
298 const agentId = e.agentId
299 if (agentId !== undefined) {
300 const status = e.reason === 'answer' ? 'done' : 'stuck'
301 await change($, mission => (mission === null ? mission : withAgent(mission, agentId, card => ended(card, status, now))))
302 } else {
303 const statuses = (await agentStatuses($)) ?? new Map<string, string>()
304 await change($, mission =>
305 isLive(mission) ? afterMainTurn(reconciled(mission, statuses, now), e.reason === 'aborted', now) : mission,
306 )
307 }
308 } catch {
309 // A missed finish is picked up at the next main turn's end.
310 }
311
312 return next(e)
313 })
314
315 // One follow-up per request when Claude used fewer helpers than the team size.
316 on('classic.Stop', async ($, e, next) => {
317 const answered = await next(e)
318 if (answered.block !== undefined) {
319 return answered
320 }
321 try {
322 const nudge: { text: string | null } = { text: null }
323 await change($, mission => {
324 nudge.text = null
325 if (!isLive(mission) || mission.size <= 1 || mission.hasNudged || mission.cards.length >= mission.size) {
326 return mission
327 }
328 nudge.text = nudgeText(mission.cards.length, mission.size)
329 return { ...mission, hasNudged: true }
330 })
331 if (nudge.text !== null) {
332 return { ...answered, block: nudge.text }
333 }
334 } catch {
335 // No nudge this time.
336 }
337
338 return answered
339 })
340
341 on('session.end', async ($, e, next) => {
342 if (writtenPath !== null) {
343 await removeFile($, writtenPath)
344 writtenPath = null
345 writtenKey = null
346 }
347
348 return next(e)
349 })
350
351 on('ui.close', { id: PANE }, async ($, e, next) => {
352 const closed = await next(e)
353 await fold($, true).catch(() => undefined)
354
355 return closed
356 })
357
358 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
359 const below = await next(e)
360 const { Box, Button } = $.ui.resolve(e)
361
362 return (
363 <Box flexDirection="row" gap={1}>
364 {below}
365 <Button key="dock-button" plain dimColor label="◆ Dock" onPress={() => toggleDock($)} />
366 </Box>
367 )
368 })
369
370 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawDock($, e))
371}
372
373async function readCountFolder($: Engine): Promise<string | null> {
374 try {
375 return countDir(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
376 } catch {
377 return null
378 }
379}
380
381// Count files a crashed session left behind; a live one is rewritten as it changes.
382async function sweepCountFiles($: Engine) {
383 try {
384 if (countFolder !== null && (await $.fs.exists(countFolder))) {
385 await $.process.run(['find', countFolder, '-type', 'f', '-name', '*.json', '-mmin', '+1440', '-delete'])
386 }
387 } catch {
388 // Leftovers are harmless: each status line reads its own session's file.
389 }
390}
391
392async function removeFile($: Engine, path: string) {
393 try {
394 await $.process.run(['rm', '-f', path])
395 } catch {
396 // Its session is over, so no status line reads it again.
397 }
398}
399
400// Writes are chained so an older count never lands after a newer one.
401function publishCount($: Engine): Promise<void> {
402 countQueue = countQueue.then(() => writeCount($)).catch(() => undefined)
403
404 return countQueue
405}
406
407async function writeCount($: Engine) {
408 if (countFolder === null) {
409 return
410 }
411 const file = countFile(await read($, missionAtom), await $.clock.now())
412 if (file === null) {
413 if (writtenPath !== null) {
414 const stale = writtenPath
415 writtenPath = null
416 writtenKey = null
417 await removeFile($, stale)
418 }
419 return
420 }
421 // A /clear goes on under a new session id, so the path can move mid-mission.
422 const path = countPath(countFolder, await $.session.id())
423 const key = `${path}|${file.working}|${file.queued}|${file.done}|${file.stuck}`
424 if (key === writtenKey) {
425 return
426 }
427 if (writtenPath !== null && writtenPath !== path) {
428 await removeFile($, writtenPath)
429 }
430 writtenPath = path
431 writtenKey = key
432 await $.fs.write(path, JSON.stringify(file))
433}
434
435async function readLimits($: Engine): Promise<number> {
436 try {
437 const subagents = await $.env.get('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS')
438 const tools = await $.env.get('CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY')
439
440 return atATime(subagents, tools)
441 } catch {
442 return atATime(undefined, undefined)
443 }
444}
445
446function agentCalls(content: readonly unknown[]): Array<{ id: string; description: string }> {
447 const calls: Array<{ id: string; description: string }> = []
448 for (const block of content as ReadonlyArray<{ type?: unknown; id?: unknown; name?: unknown; input?: unknown }>) {
449 if (block.type === 'tool_use' && block.name === 'Agent' && typeof block.id === 'string') {
450 const description = (block.input as { description?: unknown } | undefined)?.description
451 calls.push({ id: block.id, description: typeof description === 'string' ? description : 'Helper' })
452 }
453 }
454
455 return calls
456}
457
458// Helpers no person's request asked the dock to split (a slash command's, a
459// skill's, a later notification turn's) are drawn but never capped, held,
460// moved to Haiku or nudged: such a mission runs at size 1.
461async function missionFor($: Engine): Promise<DockMission> {
462 const now = await $.clock.now()
463
464 return newMission(`mission-${now}-${++missionCount}`, lastJob ?? FALLBACK_JOB, 1, now)
465}
466
467// Gives the call a card, or refuses it once the request has its team.
468async function admit($: Engine, id: string, description: string): Promise<{ size: number; deny: string | null }> {
469 const fresh = await missionFor($)
470 const verdict: { size: number; deny: string | null } = { size: 1, deny: null }
471 await change($, current => {
472 const mission = isLive(current) ? current : fresh
473 verdict.size = mission.size
474 verdict.deny = null
475 if (mission.cards.some(card => card.id === id)) {
476 return mission
477 }
478 if (mission.size > 1 && mission.cards.length >= mission.size) {
479 verdict.deny = capMessage(mission.size)
480 return current
481 }
482
483 return withQueued(mission, [{ id, description }], null)
484 })
485
486 return verdict
487}
488
489// Claude Code refuses an Agent call past its limit instead of queueing it, so
490// the dock holds the call here. A process sleep is a `$` call, which the
491// hook's ten-second budget does not count; `$.clock.sleep` would.
492async function waitForSlot($: Engine, id: string, signal: AbortSignal): Promise<boolean> {
493 for (;;) {
494 if (signal.aborted) {
495 return true
496 }
497 const listed = await activeAgents($)
498 const mission = await read($, missionAtom)
499 const working = mission?.cards.filter(card => card.status === 'working' && !launching.has(card.id)).length ?? 0
500 // Checked and taken in one step, so two held calls never take one slot.
501 if ((listed ?? working) + launching.size < atOnce) {
502 launching.add(id)
503 return false
504 }
505 await $.process.run(['/bin/sleep', '1'])
506 }
507}
508
509async function activeAgents($: Engine): Promise<number | null> {
510 try {
511 return (await $.agent.list()).filter(agent => ACTIVE_STATUSES.has(agent.status)).length
512 } catch {
513 return null
514 }
515}
516
517async function agentStatuses($: Engine): Promise<Map<string, string> | null> {
518 try {
519 return new Map((await $.agent.list()).map(agent => [agent.id, agent.status]))
520 } catch {
521 return null
522 }
523}
524
525async function startCard($: Engine, id: string) {
526 const now = await $.clock.now()
527 await change($, mission => (mission === null ? mission : withCard(mission, id, card => started(card, now))))
528}
529
530async function finish($: Engine, id: string, status: 'done' | 'stuck') {
531 const now = await $.clock.now()
532 await change($, mission => (mission === null ? mission : withCard(mission, id, card => ended(card, status, now))))
533}
534
535// A background helper finishes with its own turn.complete; a foreground one,
536// or one whose spawn the dock never saw, finishes when its call returns.
537async function afterCall($: Engine, id: string, ran: ToolCallResult) {
538 if (ran.deny !== undefined) {
539 await change($, mission => (mission === null ? mission : withoutCard(mission, id)))
540 return
541 }
542 if (ran.isError === true) {
543 await finish($, id, 'stuck')
544 return
545 }
546 const card = (await read($, missionAtom))?.cards.find(one => one.id === id)
547 if (card?.isBackground !== true || card.agentId === null) {
548 await finish($, id, 'done')
549 }
550}
551
552async function change($: Engine, fn: (mission: DockMission | null) => DockMission | null): Promise<DockMission | null> {
553 const mission = await update($, missionAtom, fn)
554 syncTicker($, mission)
555 await refreshBadge($, mission)
556 await publishCount($)
557
558 return mission
559}
560
561// One clock, and only while a mission is live.
562function syncTicker($: Engine, mission: DockMission | null) {
563 const shouldTick = isLive(mission)
564 if (shouldTick && ticker === null) {
565 ticker = $.clock.every(FRAME_MS, () => {
566 void update($, tickAtom, frame => frame + 1)
567 })
568 } else if (!shouldTick && ticker !== null) {
569 ticker.cancel()
570 ticker = null
571 }
572}
573
574async function refreshBadge($: Engine, known?: DockMission | null) {
575 const isFolded = await read($, foldedAtom)
576 const mission = known === undefined ? await read($, missionAtom) : known
577 const text = isFolded ? badgeText(mission, await read($, sizeAtom)) : undefined
578 if (text !== shownStatus) {
579 shownStatus = text
580 $.ui.status(text)
581 }
582}
583
584async function fold($: Engine, isFolded: boolean) {
585 await update($, foldedAtom, () => isFolded)
586 await refreshBadge($)
587}
588
589async function isDockOpen($: Engine): Promise<boolean> {
590 return (await $.ui.panes()).some(pane => pane.id === PANE)
591}
592
593async function toggleDock($: Engine) {
594 try {
595 if (await isDockOpen($)) {
596 await $.ui.close({ id: PANE })
597 await fold($, true)
598 } else {
599 await showDock($)
600 }
601 } catch {
602 $.ui.toast('The Agent Dock could not open. Try /dock again.')
603 }
604}
605
606// Opened from a command or a press, so it seats at any width; `columns` docks
607// it beside the transcript in fullscreen.
608async function showDock($: Engine) {
609 const opened = await $.ui.open({ id: PANE, title: 'Agent Dock', columns: DOCK_COLUMNS })
610 if (opened.isPlaced) {
611 await fold($, false)
612 } else {
613 $.ui.toast(TOO_NARROW)
614 await fold($, true)
615 }
616}
617
618async function chooseSize($: Engine, size: number) {
619 await update($, customAtom, () => false)
620 if (isBigTeam(size)) {
621 await update($, pendingAtom, () => size)
622 return
623 }
624 await commitSize($, size)
625}
626
627async function commitSize($: Engine, size: number) {
628 await update($, pendingAtom, () => null)
629 await update($, sizeAtom, () => size)
630 await $.store.set(SIZE_KEY, size)
631 await refreshBadge($)
632}
633
634async function confirmBigTeam($: Engine) {
635 const pending = await read($, pendingAtom)
636 if (pending !== null) {
637 await commitSize($, pending)
638 }
639}
640
641async function submitCustom($: Engine, text: string) {
642 const size = parseSize(text)
643 if (size === null) {
644 $.ui.toast(BAD_CUSTOM)
645 return
646 }
647 await chooseSize($, size)
648}
649
650async function pickModel($: Engine, model: DockHelperModel) {
651 await update($, modelAtom, () => model)
652 await $.store.set(MODEL_KEY, model)
653}
654
655type Elements = ReturnType<Engine['ui']['resolve']>
656type TextElement = Elements['Text']
657
658async function drawDock($: Engine, e: RenderInput<'Pane'>) {
659 const { Box, Text, Button } = $.ui.resolve(e)
660 const columns = Math.max(24, e.props.bodyColumns)
661 const [size, model, pending, isCustomOpen, mission] = await Promise.all([
662 read($, sizeAtom),
663 read($, modelAtom),
664 read($, pendingAtom),
665 read($, customAtom),
666 read($, missionAtom),
667 ])
668 const now = await $.clock.now()
669 // Only a live mission reads the clock, so nothing redraws while idle.
670 const frame = isLive(mission) ? await read($, tickAtom) : 0
671
672 const chip = (label: string) => (
673 <Text backgroundColor={CORAL} color={INK} bold>
674 {label}
675 </Text>
676 )
677 const isPreset = (SIZES as readonly number[]).includes(size)
678
679 const customBox = isCustomOpen ? (
680 <Box borderStyle="round" borderColor={CORAL} paddingX={1} flexDirection="row" gap={2}>
681 {e.surface === 'mobile' ? (
682 <Text>Type /dock and a number from 1 to 100</Text>
683 ) : (
684 (() => {
685 const { Input } = $.ui.resolve(e)
686 return (
687 <Input
688 key="custom-size"
689 label="How many helpers? "
690 placeholder="1 to 100"
691 submitLabel="set"
692 autoFocus
693 onSubmit={value => submitCustom($, value)}
694 />
695 )
696 })()
697 )}
698 <Button key="custom-cancel" plain label="Cancel" onPress={() => update($, customAtom, () => false)} />
699 </Box>
700 ) : null
701
702 return (
703 <Box flexDirection="column">
704 <Box flexDirection="row" justifyContent="space-between">
705 <Text>
706 <Text color={CORAL}>{'◆ '}</Text>
707 {wordmark(Text)}
708 </Text>
709 {liveMark(Text, mission, frame)}
710 </Box>
711 <Text color={HAIRLINE}>{'─'.repeat(columns)}</Text>
712
713 <Box flexDirection="row" flexWrap="wrap">
714 <Text color={MUTED}>{`${spaced('TEAM SIZE')} `}</Text>
715 <Text color={HAIRLINE}>{'╭ '}</Text>
716 {SIZES.map(option =>
717 option === size ? (
718 chip(` ${option} `)
719 ) : (
720 <Button key={`size-${option}`} plain label={` ${option} `} onPress={() => chooseSize($, option)} />
721 ),
722 )}
723 <Text color={HAIRLINE}>{' │ '}</Text>
724 {isPreset ? null : chip(` ${size} `)}
725 <Button key="size-custom" plain label=" Custom " onPress={() => update($, customAtom, () => true)} />
726 <Text color={HAIRLINE}>{' ╮'}</Text>
727 </Box>
728 <Text color={MUTED} wrap="truncate-end">
729 {infoLine(size, atOnce, model)}
730 </Text>
731 <Box flexDirection="row">
732 <Text color={MUTED}>{'Helper agents '}</Text>
733 {model === 'fast' ? chip(' Fast & Cheap ') : <Button key="model-fast" plain label=" Fast & Cheap " onPress={() => pickModel($, 'fast')} />}
734 <Text> </Text>
735 {model === 'same' ? chip(' Same as me ') : <Button key="model-same" plain label=" Same as me " onPress={() => pickModel($, 'same')} />}
736 </Box>
737 {customBox}
738 {pending === null ? null : (
739 <Box borderStyle="round" borderColor={GOLD} paddingX={1} flexDirection="column">
740 <Text color={GOLD}>Big team: this uses your plan quickly. Continue?</Text>
741 <Box flexDirection="row" gap={2}>
742 <Button key="big-continue" variant="primary" autoFocus label={`Continue with ${pending}`} onPress={() => confirmBigTeam($)} />
743 <Button key="big-cancel" label="Cancel" onPress={() => update($, pendingAtom, () => null)} />
744 </Box>
745 </Box>
746 )}
747 <Text> </Text>
748 {mission === null ? idle(Box, Text, size) : drawMission($, e, mission, columns, now, frame)}
749 </Box>
750 )
751}
752
753function wordmark(Text: TextElement) {
754 const letters = [...spaced('AGENT DOCK')]
755 const last = Math.max(1, letters.length - 1)
756
757 return letters.map((letter, index) => (
758 <Text color={mix(CORAL, GOLD, index / last)} bold>
759 {letter}
760 </Text>
761 ))
762}
763
764function liveMark(Text: TextElement, mission: DockMission | null, frame: number) {
765 if (mission === null) {
766 return <Text color={MUTED}>{spaced('STANDING BY')}</Text>
767 }
768 if (mission.finishedAt !== null) {
769 return <Text color={GREEN}>{spaced('COMPLETE')}</Text>
770 }
771
772 // A slow blink: bright for three frames, soft for two.
773 return (
774 <Text color={frame % 5 < 3 ? GREEN : '#1F7A45'} bold>
775 {`● ${spaced('LIVE')}`}
776 </Text>
777 )
778}
779
780function idle(Box: Elements['Box'], Text: TextElement, size: number) {
781 const [standing, hint] = standingBy(size)
782
783 return (
784 <Box flexDirection="column">
785 <Text>
786 {seats(size).map((seat, index) => (
787 <Text color={seat.color} dimColor={seat.isSoft}>
788 {index === 0 ? '●' : ' ●'}
789 </Text>
790 ))}
791 </Text>
792 <Text bold>{standing}</Text>
793 <Text color={MUTED}>{hint}</Text>
794 </Box>
795 )
796}
797
798function drawMission($: Engine, e: RenderInput<'Pane'>, mission: DockMission, columns: number, now: number, frame: number) {
799 const { Box, Text } = $.ui.resolve(e)
800 const tally = counts(mission.cards)
801 const elapsed = `${missionPercent(mission.cards)}% ${formatClock((mission.finishedAt ?? now) - mission.startedAt)}`
802 const isAllBack = mission.cards.length > 0 && mission.cards.every(card => isFinished(card.status))
803 const useTiles = mission.cards.length > TILES_AFTER
804
805 return (
806 <Box flexDirection="column">
807 <Box flexDirection="row" justifyContent="space-between">
808 <Box width={Math.max(8, columns - elapsed.length - 2)}>
809 <Text wrap="truncate-end">
810 <Text color={MUTED}>{`${spaced('MISSION')} `}</Text>
811 <Text bold>{mission.job}</Text>
812 </Text>
813 </Box>
814 <Text bold>{elapsed}</Text>
815 </Box>
816 <Text>{parts(Text, missionBar(mission.cards, columns, frame))}</Text>
817 <Box flexDirection="row" gap={4}>
818 <Text color={GREEN}>{`● ${tally.working} working`}</Text>
819 <Text color={MUTED}>{`○ ${tally.queued} queued`}</Text>
820 <Text color={GOLD}>{`✓ ${tally.done} done`}</Text>
821 <Text color={tally.stuck > 0 ? RED : MUTED}>{`✕ ${tally.stuck} stuck`}</Text>
822 </Box>
823 {mission.finishedAt !== null ? (
824 <Box borderStyle="round" borderColor={GREEN} paddingX={1}>
825 <Text>
826 <Text color={GREEN}>{'✓ '}</Text>
827 {summary(mission)}
828 </Text>
829 </Box>
830 ) : mission.cards.length === 0 ? (
831 <Text color={MUTED}>{`Claude is splitting the work across ${mission.size} helpers…`}</Text>
832 ) : isAllBack ? (
833 <Text color={MUTED}>Every helper is back. Claude is combining their work…</Text>
834 ) : null}
835 <Text> </Text>
836 {useTiles ? tiles($, e, mission.cards, columns, frame) : cards($, e, mission.cards, columns, now, frame)}
837 </Box>
838 )
839}
840
841function cards($: Engine, e: RenderInput<'Pane'>, list: readonly DockCard[], columns: number, now: number, frame: number) {
842 const { Box, Text } = $.ui.resolve(e)
843 const perRow = cardsPerRow(columns)
844 const width = Math.floor((columns - (perRow - 1)) / perRow)
845 const inner = width - 4
846
847 return (
848 <Box flexDirection="column">
849 {rows(list, perRow).map(row => (
850 <Box flexDirection="row" gap={1}>
851 {row.map(card => {
852 const isQueued = card.status === 'queued'
853 const time = cardTime(card, now)
854 const label = percentLabel(card).padStart(5)
855 return (
856 <Box
857 key={`card-${card.id}`}
858 width={width}
859 flexDirection="column"
860 borderStyle="round"
861 borderColor={card.status === 'stuck' ? RED : HAIRLINE}
862 hover={{ borderColor: CORAL }}
863 paddingX={1}
864 >
865 <Box flexDirection="row">
866 {face(Text, card)}
867 <Text> </Text>
868 <Box width={Math.max(4, inner - 6 - time.length)}>
869 <Text wrap="truncate-end" bold={card.status === 'working'} dimColor={isQueued}>
870 {card.task}
871 </Text>
872 </Box>
873 <Text> </Text>
874 <Text color={MUTED} dimColor={isQueued}>
875 {time}
876 </Text>
877 </Box>
878 <Text>
879 {' '}
880 {parts(Text, meter(card, Math.max(1, inner - 10), frame))}
881 <Text dimColor={isQueued} color={card.status === 'stuck' ? RED : undefined}>
882 {label}
883 </Text>
884 </Text>
885 </Box>
886 )
887 })}
888 </Box>
889 ))}
890 </Box>
891 )
892}
893
894// Past twelve helpers each one is a one-line tile, so 50 or 100 still fit.
895function tiles($: Engine, e: RenderInput<'Pane'>, list: readonly DockCard[], columns: number, frame: number) {
896 const { Box, Text } = $.ui.resolve(e)
897 const perRow = tilesPerRow(columns, TILE_WIDTH)
898
899 return (
900 <Box flexDirection="column">
901 {rows(list, perRow).map(row => (
902 <Box flexDirection="row" gap={1}>
903 {row.map(card => (
904 <Box key={`tile-${card.id}`} width={TILE_WIDTH} flexDirection="row">
905 {face(Text, card)}
906 <Text> </Text>
907 <Text>{parts(Text, meter(card, TILE_WIDTH - 10, frame))}</Text>
908 <Text dimColor={card.status === 'queued'} color={card.status === 'stuck' ? RED : undefined}>
909 {percentLabel(card).padStart(5)}
910 </Text>
911 </Box>
912 ))}
913 </Box>
914 ))}
915 </Box>
916 )
917}
918
919// The terminal draws no headshots here, so every face is a two-letter badge.
920function face(Text: TextElement, card: DockCard) {
921 return (
922 <Text
923 backgroundColor={BADGE_COLORS[card.colorIndex] ?? CORAL}
924 color={INK}
925 bold
926 dimColor={card.status === 'queued'}
927 hover={{ backgroundColor: CORAL }}
928 >
929 {` ${card.initials} `}
930 </Text>
931 )
932}
933
934function percentLabel(card: DockCard): string {
935 switch (card.status) {
936 case 'queued':
937 return '—'
938 case 'stuck':
939 return '✕'
940 case 'done':
941 return '100%'
942 case 'working':
943 return card.hasReported ? `${card.percent}%` : '…'
944 }
945}
946
947function parts(Text: TextElement, list: MeterPart[]) {
948 return list.map(part => <Text color={part.color}>{part.text}</Text>)
949}
950hooks/clean-name.ts 48 lines1export const MAX_NAME_LENGTH = 40
2
3const FALLBACK_NAME = 'Working on it'
4
5const CODE_EXTENSIONS = [
6 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'py', 'rb', 'go', 'rs',
7 'java', 'kt', 'swift', 'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'php', 'sh', 'bash',
8 'zsh', 'ps1', 'sql', 'json', 'yaml', 'yml', 'toml', 'ini', 'xml', 'html', 'htm',
9 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'md', 'mdx', 'lock', 'env',
10 'sol', 'lua', 'dart', 'scala', 'ex', 'exs', 'hs', 'tf', 'gradle', 'ipynb',
11]
12
13const BACKTICK_CODE = /`[^`]*`/g
14const WITH_SLASH = /\S*[/\\]\S*/g
15const FILE_NAME = new RegExp(`[\\w.-]*\\.(?:${CODE_EXTENSIONS.join('|')})(?![\\w.])`, 'gi')
16const EMPTY_BRACKETS = /\(\s*\)|\[\s*\]|\{\s*\}/g
17const EDGE_PUNCTUATION = /^[\s,;:–—-]+|[\s,;:–—-]+$/g
18
19// One cleaner for every name the checklist shows: no code, paths or file
20// names, at most 40 characters, never empty.
21export function cleanName(raw: string): string {
22 const name = raw
23 .replace(BACKTICK_CODE, ' ')
24 .replace(/`/g, ' ')
25 .replace(WITH_SLASH, ' ')
26 .replace(FILE_NAME, ' ')
27 .replace(EMPTY_BRACKETS, ' ')
28 .replace(/\s+/g, ' ')
29 .replace(/\s+([,.;:!?])/g, '$1')
30 .replace(EDGE_PUNCTUATION, '')
31
32 if (name === '') {
33 return FALLBACK_NAME
34 }
35
36 const capitalised = name[0]!.toUpperCase() + name.slice(1)
37
38 return capitalised.length <= MAX_NAME_LENGTH ? capitalised : shorten(capitalised)
39}
40
41function shorten(name: string): string {
42 const room = name.slice(0, MAX_NAME_LENGTH - 1)
43 const lastSpace = room.lastIndexOf(' ')
44 const cut = lastSpace > 0 ? room.slice(0, lastSpace) : room
45
46 return cut.replace(EDGE_PUNCTUATION, '') + '…'
47}
48hooks/progress.ts 23 lines1// Shared by Clean View and the Agent Dock, so a percent and a duration read
2// the same on every surface.
3
4export function clampPercent(value: unknown): number {
5 const percent = Number(value)
6
7 return Number.isFinite(percent) ? Math.round(Math.min(100, Math.max(0, percent))) : 0
8}
9
10// 134_000 → "2m 14s"
11export function formatDuration(ms: number): string {
12 const seconds = Math.max(0, Math.floor(ms / 1000))
13 if (seconds < 60) {
14 return `${seconds}s`
15 }
16 const minutes = Math.floor(seconds / 60)
17 if (minutes < 60) {
18 return `${minutes}m ${seconds % 60}s`
19 }
20
21 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
22}
23hooks/dock-logic.ts 484 lines1import type { DockCard, DockCardStatus, DockHelperModel, DockMission } from '../types'
2import { cleanName } from './clean-name'
3import { formatDuration } from './progress'
4
5export const SIZES = [1, 3, 5, 10, 20, 50, 100] as const
6export const MAX_SIZE = 100
7export const BIG_TEAM = 20
8export const MAX_SEATS = 25
9export const TILES_AFTER = 12
10
11// What Claude Code runs at once when nothing in settings says otherwise.
12export const DEFAULT_SUBAGENT_LIMIT = 20
13export const DEFAULT_TOOL_LIMIT = 10
14
15export const SIZE_HELP = 'Team Size is a whole number from 1 to 100, e.g. /dock 10.'
16export const BAD_CUSTOM = 'Type a whole number from 1 to 100'
17export const STOPPED = 'Stopped before this helper started.'
18export const HELPERS_SKIP_PLAN = 'Helpers skip the plan. Just call report_progress as you work.'
19export const TOO_NARROW = 'The window is too narrow to show the Agent Dock. Widen it or watch the status bar.'
20
21export const CORAL = '#FF7A66'
22export const GOLD = '#F2C14E'
23export const GREEN = '#4ADE80'
24export const GREEN_LIGHT = '#BBF7D0'
25export const RED = '#F87171'
26export const MUTED = '#8B90A0'
27export const HAIRLINE = '#3A3F4B'
28export const INK = '#16181D'
29
30// Muted, not a rainbow: each helper's badge cycles through these.
31export const BADGE_COLORS = ['#E07A5F', '#F2CC8F', '#81B29A', '#7FA7C9', '#B5A1D6', '#E5989B', '#9CC5A1', '#D4A373']
32
33export function parseSize(text: string): number | null {
34 const trimmed = text.trim()
35 if (!/^\d+$/.test(trimmed)) {
36 return null
37 }
38 const size = Number(trimmed)
39
40 return size >= 1 && size <= MAX_SIZE ? size : null
41}
42
43// A big team never carries over: a new session starts it at 1.
44export function restoreSize(saved: unknown): number {
45 return typeof saved === 'number' && Number.isInteger(saved) && saved >= 1 && saved <= BIG_TEAM ? saved : 1
46}
47
48export function isBigTeam(size: number): boolean {
49 return size > BIG_TEAM
50}
51
52export function restoreHelperModel(saved: unknown): DockHelperModel {
53 return saved === 'same' ? 'same' : 'fast'
54}
55
56export function parseLimit(raw: string | undefined, fallback: number): number {
57 const limit = Number(raw)
58
59 return Number.isInteger(limit) && limit > 0 ? limit : fallback
60}
61
62// Helpers running at once: Claude Code refuses an Agent call past either limit.
63export function atATime(subagentLimit: string | undefined, toolLimit: string | undefined): number {
64 return Math.min(parseLimit(subagentLimit, DEFAULT_SUBAGENT_LIMIT), parseLimit(toolLimit, DEFAULT_TOOL_LIMIT))
65}
66
67export function jobName(request: string): string {
68 const firstLine = request.trim().split('\n')[0] ?? ''
69 const clause = firstLine.split(/[.;!?](?:\s|$)|,\s|\s[–—-]\s/)[0] ?? firstLine
70
71 return cleanName(clause)
72}
73
74export function taskName(description: string): string {
75 return cleanName(description)
76}
77
78// "Price check: Panera" → PA, "Read the docs" → RT.
79export function initials(task: string): string {
80 const subject = task.includes(':') ? task.slice(task.indexOf(':') + 1) : task
81 const words = subject.match(/[\p{L}\p{N}]+/gu) ?? task.match(/[\p{L}\p{N}]+/gu) ?? []
82 const [first, second] = words
83 if (first === undefined) {
84 return '··'
85 }
86 if (second === undefined) {
87 return first.slice(0, 2).toUpperCase().padEnd(2, '·')
88 }
89
90 return (first[0]! + second[0]!).toUpperCase()
91}
92
93export function helperNote(progressTool: string, planTool: string): string {
94 return (
95 `As you work, call ${progressTool} with your task name and a percent at about 25, 50, 75 and 100. ` +
96 `Do not call ${planTool}.`
97 )
98}
99
100export function teamInstruction(size: number, progressTool: string, planTool: string): string {
101 return [
102 `Agent Dock: Team Size is ${size}.`,
103 `- Split this request into exactly ${size} independent pieces and launch one helper (the Agent tool) per piece, all ${size} in one message so they run in parallel.`,
104 '- Find a real split, one helper per item (per store, per task, per file, per section). Never argue that it can\'t be split and never pad with useless work.',
105 '- Give each helper a short plain-English description of 3 to 5 words, e.g. "Price check: Panera".',
106 `- In each helper's prompt add: "${helperNote(progressTool, planTool)}"`,
107 '- When they finish, combine their results into one answer.',
108 ].join('\n')
109}
110
111export function nudgeText(used: number, size: number): string {
112 return (
113 `You used ${used} of ${size} helpers. Split the remaining work across the other ${size - used}, ` +
114 'one helper per piece, all in parallel.'
115 )
116}
117
118export function capMessage(size: number): string {
119 return `Team Size is ${size}: this request already has ${size} helpers. Finish with the helpers you have.`
120}
121
122export function confirmMessage(size: number): string {
123 return `Confirm the team of ${size} in the Agent Dock.`
124}
125
126export function infoLine(size: number, atOnce: number, model: DockHelperModel): string {
127 const split = size === 1 ? 'Claude decides how many helpers' : `Splits each request across ${size} helpers`
128 const running = `${size === 1 ? atOnce : Math.min(size, atOnce)} at a time`
129 const helpers = size > 1 && model === 'fast' ? 'Fast & Cheap' : 'Same model as you'
130
131 return [split, running, helpers].join(' · ')
132}
133
134export type DockCounts = { working: number; queued: number; done: number; stuck: number; total: number }
135
136export function counts(cards: readonly DockCard[]): DockCounts {
137 const tally: DockCounts = { working: 0, queued: 0, done: 0, stuck: 0, total: cards.length }
138 for (const card of cards) {
139 tally[card.status] += 1
140 }
141
142 return tally
143}
144
145export function isFinished(status: DockCardStatus): boolean {
146 return status === 'done' || status === 'stuck'
147}
148
149// A stuck helper is over too, so it counts as a full share of the bar.
150export function missionPercent(cards: readonly DockCard[]): number {
151 if (cards.length === 0) {
152 return 0
153 }
154 const sum = cards.reduce((total, card) => total + (isFinished(card.status) ? 100 : card.percent), 0)
155
156 return Math.round(sum / cards.length)
157}
158
159export function isLive(mission: DockMission | null): mission is DockMission {
160 return mission !== null && mission.finishedAt === null
161}
162
163export function badgeText(mission: DockMission | null, size: number): string {
164 if (mission === null || mission.cards.length === 0) {
165 return `◆ Dock · team of ${size}`
166 }
167 const tally = counts(mission.cards)
168 const parts = [`${tally.working} working`, `${tally.queued} queued`, `${tally.done} done`]
169 if (tally.stuck > 0) {
170 parts.push(`${tally.stuck} stuck`)
171 }
172
173 return parts.join(' · ')
174}
175
176export type CountFile = { working: number; queued: number; done: number; stuck: number; job: string; updatedAt: number }
177
178// What the status line reads for this session; null while no helper runs.
179export function countFile(mission: DockMission | null, now: number): CountFile | null {
180 if (mission === null) {
181 return null
182 }
183 const tally = counts(mission.cards)
184 if (tally.working + tally.queued === 0) {
185 return null
186 }
187
188 return { working: tally.working, queued: tally.queued, done: tally.done, stuck: tally.stuck, job: mission.job, updatedAt: now }
189}
190
191// The global folder the status line reads, beside Claude Code's own settings.
192export function countDir(configDir: string | undefined, home: string | undefined): string | null {
193 const base = configDir !== undefined && configDir !== '' ? configDir : home !== undefined && home !== '' ? `${home}/.claude` : null
194
195 return base === null ? null : `${base.replace(/\/+$/, '')}/agent-dock/agents-now`
196}
197
198export function countPath(dir: string, sessionId: string): string {
199 return `${dir}/${sessionId.replace(/[^A-Za-z0-9._-]/g, '_')}.json`
200}
201
202// 72_000 → "1:12"
203export function formatClock(ms: number): string {
204 const seconds = Math.max(0, Math.floor(ms / 1000))
205 const minutes = Math.floor(seconds / 60)
206
207 return `${minutes}:${String(seconds % 60).padStart(2, '0')}`
208}
209
210export function summary(mission: DockMission): string {
211 const total = mission.cards.length
212 const stuck = counts(mission.cards).stuck
213 const took = formatDuration((mission.finishedAt ?? mission.startedAt) - mission.startedAt)
214 const line = `${total} ${total === 1 ? 'agent' : 'agents'} finished ${mission.job} in ${took}`
215
216 return stuck > 0 ? `${line} (${stuck} got stuck)` : line
217}
218
219export function cardTime(card: DockCard, now: number): string {
220 if (card.startedAt === null) {
221 return '0:00'
222 }
223
224 return formatClock((card.finishedAt ?? now) - card.startedAt)
225}
226
227export function newMission(id: string, job: string, size: number, now: number): DockMission {
228 return { id, job, size, cards: [], hasNudged: false, startedAt: now, finishedAt: null }
229}
230
231export function newCard(id: string, description: string, index: number): DockCard {
232 const task = taskName(description)
233
234 return {
235 id,
236 task,
237 initials: initials(task),
238 colorIndex: index % BADGE_COLORS.length,
239 status: 'queued',
240 percent: 0,
241 hasReported: false,
242 agentId: null,
243 isBackground: null,
244 startedAt: null,
245 finishedAt: null,
246 }
247}
248
249// At a team size above 1 no card is drawn past the size: those calls are refused.
250export function withQueued(
251 mission: DockMission,
252 calls: ReadonlyArray<{ id: string; description: string }>,
253 cap: number | null,
254): DockMission {
255 const cards = [...mission.cards]
256 for (const call of calls) {
257 if (cards.some(card => card.id === call.id) || (cap !== null && cards.length >= cap)) {
258 continue
259 }
260 cards.push(newCard(call.id, call.description, cards.length))
261 }
262
263 return { ...mission, cards }
264}
265
266export function withCard(mission: DockMission, id: string, change: (card: DockCard) => DockCard): DockMission {
267 return { ...mission, cards: mission.cards.map(card => (card.id === id ? change(card) : card)) }
268}
269
270export function withAgent(mission: DockMission, agentId: string, change: (card: DockCard) => DockCard): DockMission {
271 return { ...mission, cards: mission.cards.map(card => (card.agentId === agentId ? change(card) : card)) }
272}
273
274// Helpers the engine reports finished whose own turn.complete never reached the dock.
275export function reconciled(mission: DockMission, statuses: ReadonlyMap<string, string>, now: number): DockMission {
276 const cards = mission.cards.map(card => {
277 const status = card.status === 'working' && card.agentId !== null ? statuses.get(card.agentId) : undefined
278 if (status === 'completed') {
279 return ended(card, 'done', now)
280 }
281
282 return status === 'failed' || status === 'killed' ? ended(card, 'stuck', now) : card
283 })
284
285 return { ...mission, cards }
286}
287
288// The agent statuses that hold one of Claude Code's subagent slots.
289export const ACTIVE_STATUSES = new Set(['running', 'pending', 'waiting'])
290
291export function hasActiveHelper(mission: DockMission, statuses: ReadonlyMap<string, string>): boolean {
292 return mission.cards.some(card => !isFinished(card.status) && card.agentId !== null && ACTIVE_STATUSES.has(statuses.get(card.agentId) ?? ''))
293}
294
295// Closes a mission the engine has no helper running for, so a missed finish
296// never leaves the next request unsplit. A helper that started and is gone
297// counts as done; one that never started counts as stuck.
298export function closedOut(mission: DockMission, statuses: ReadonlyMap<string, string>, now: number): DockMission | null {
299 const settled = reconciled(mission, statuses, now)
300 const cards = settled.cards.map(card =>
301 isFinished(card.status) ? card : ended(card, card.status === 'working' && card.agentId !== null ? 'done' : 'stuck', now),
302 )
303
304 return cards.length === 0 ? null : { ...settled, cards, finishedAt: now }
305}
306
307export function withoutCard(mission: DockMission, id: string): DockMission {
308 return { ...mission, cards: mission.cards.filter(card => card.id !== id) }
309}
310
311export function started(card: DockCard, now: number): DockCard {
312 return card.status === 'queued' ? { ...card, status: 'working', startedAt: now } : card
313}
314
315export function ended(card: DockCard, status: 'done' | 'stuck', now: number): DockCard {
316 if (isFinished(card.status)) {
317 return card
318 }
319
320 return { ...card, status, percent: status === 'done' ? 100 : card.percent, startedAt: card.startedAt ?? now, finishedAt: now }
321}
322
323export function reported(card: DockCard, percent: number): DockCard {
324 return isFinished(card.status) ? card : { ...card, percent, hasReported: true }
325}
326
327// The main turn ended: a mission whose helpers have all finished is complete,
328// and one that never launched a helper goes back to standing by.
329export function afterMainTurn(mission: DockMission, isAborted: boolean, now: number): DockMission | null {
330 const cards = isAborted ? mission.cards.map(card => (card.status === 'queued' ? ended(card, 'stuck', now) : card)) : mission.cards
331 if (cards.length === 0) {
332 return null
333 }
334
335 return cards.every(card => isFinished(card.status)) ? { ...mission, cards, finishedAt: now } : { ...mission, cards }
336}
337
338export function spaced(text: string): string {
339 return text
340 .split(' ')
341 .map(word => [...word].join(' '))
342 .join(' ')
343}
344
345// A color run along a gradient: `cells` cells split into up to `steps` runs.
346export function gradientRuns(cells: number, from: string, to: string, steps = 8): Array<{ cells: number; color: string }> {
347 if (cells <= 0) {
348 return []
349 }
350 const runs = Math.min(steps, cells)
351 const out: Array<{ cells: number; color: string }> = []
352 let used = 0
353 for (let run = 0; run < runs; run++) {
354 const end = Math.round(((run + 1) * cells) / runs)
355 out.push({ cells: end - used, color: mix(from, to, runs === 1 ? 0 : run / (runs - 1)) })
356 used = end
357 }
358
359 return out
360}
361
362export function mix(from: string, to: string, at: number): string {
363 const a = rgb(from)
364 const b = rgb(to)
365 const channel = (index: number) =>
366 Math.round(a[index]! + (b[index]! - a[index]!) * at)
367 .toString(16)
368 .padStart(2, '0')
369
370 return `#${channel(0)}${channel(1)}${channel(2)}`
371}
372
373function rgb(hex: string): [number, number, number] {
374 const value = parseInt(hex.slice(1), 16)
375
376 return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
377}
378
379export type MeterPart = { text: string; color: string }
380
381// Cells of one meter: done in coral to gold, a reported percent in green, a
382// three-cell sweep while a helper has not reported yet, a hairline queued.
383export function meter(card: DockCard, cells: number, frame: number): MeterPart[] {
384 if (cells <= 0) {
385 return []
386 }
387 if (card.status === 'queued') {
388 return [{ text: '─'.repeat(cells), color: HAIRLINE }]
389 }
390 if (card.status === 'done') {
391 return gradientRuns(cells, CORAL, GOLD, 4).map(run => ({ text: '━'.repeat(run.cells), color: run.color }))
392 }
393 if (card.status === 'stuck') {
394 const filled = Math.max(1, Math.round((card.percent / 100) * cells))
395 return [
396 { text: '━'.repeat(filled), color: RED },
397 { text: '─'.repeat(cells - filled), color: HAIRLINE },
398 ]
399 }
400 if (!card.hasReported) {
401 const width = Math.min(3, cells)
402 const start = (frame % (cells + width)) - width
403 const parts: MeterPart[] = []
404 for (let cell = 0; cell < cells; cell++) {
405 const isLit = cell >= start && cell < start + width
406 parts.push({ text: isLit ? '━' : '─', color: isLit ? GREEN : HAIRLINE })
407 }
408 return merge(parts)
409 }
410 const filled = Math.round((card.percent / 100) * cells)
411
412 return merge([
413 { text: '━'.repeat(filled), color: GREEN },
414 { text: '─'.repeat(cells - filled), color: HAIRLINE },
415 ])
416}
417
418// The mission bar: finished helpers sweep coral to gold, working ones
419// shimmer green, the rest is a hairline.
420export function missionBar(cards: readonly DockCard[], cells: number, frame: number): MeterPart[] {
421 if (cells <= 0) {
422 return []
423 }
424 const tally = counts(cards)
425 const total = Math.max(1, tally.total)
426 const finished = Math.round(((tally.done + tally.stuck) / total) * cells)
427 const working = Math.min(cells - finished, Math.round((tally.working / total) * cells))
428 const parts: MeterPart[] = gradientRuns(finished, CORAL, GOLD).map(run => ({ text: '━'.repeat(run.cells), color: run.color }))
429 const glint = working > 0 ? frame % working : -1
430 for (let cell = 0; cell < working; cell++) {
431 parts.push({ text: '━', color: cell === glint ? GREEN_LIGHT : GREEN })
432 }
433 parts.push({ text: '─'.repeat(cells - finished - working), color: HAIRLINE })
434
435 return merge(parts.filter(part => part.text !== ''))
436}
437
438function merge(parts: MeterPart[]): MeterPart[] {
439 const out: MeterPart[] = []
440 for (const part of parts) {
441 const last = out[out.length - 1]
442 if (last !== undefined && last.color === part.color) {
443 last.text += part.text
444 } else if (part.text !== '') {
445 out.push({ ...part })
446 }
447 }
448
449 return out
450}
451
452export function cardsPerRow(columns: number): number {
453 return columns >= 98 ? 3 : columns >= 64 ? 2 : 1
454}
455
456export function tilesPerRow(columns: number, tileWidth: number): number {
457 return Math.max(1, Math.floor((columns + 1) / (tileWidth + 1)))
458}
459
460export function rows<T>(items: readonly T[], perRow: number): T[][] {
461 const out: T[][] = []
462 for (let at = 0; at < items.length; at += perRow) {
463 out.push(items.slice(at, at + perRow))
464 }
465
466 return out
467}
468
469// The idle row: one seat per team member, up to 25, in a fixed shimmer of
470// bright and soft seats (no clock runs while idle).
471export function seats(size: number): Array<{ color: string; isSoft: boolean }> {
472 return Array.from({ length: Math.min(size, MAX_SEATS) }, (_, index) => ({
473 color: BADGE_COLORS[index % BADGE_COLORS.length]!,
474 isSoft: (index * 7) % 5 < 2,
475 }))
476}
477
478export function standingBy(size: number): [string, string] {
479 return [
480 `Your team of ${size} is standing by`,
481 size === 1 ? 'Send a request and Claude decides how many helpers to use.' : `Send a request and it splits across ${size} helpers.`,
482 ]
483}
484types/index.d.ts 83 lines1export type CleanViewPhase = 'working' | 'needsYou' | 'stuck' | 'stopped' | 'done'
2
3export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
4
5export type CleanViewTask = {
6 id: string
7 name: string
8 status: CleanViewTaskStatus
9 percent: number
10 hasReported: boolean
11}
12
13// Where the real plan came from; null while only the placeholder steps show.
14export type CleanViewPlanSource = 'steps' | 'todos' | 'tasks'
15
16export type CleanViewChecklist = {
17 jobId: string
18 title: string
19 phase: CleanViewPhase
20 tasks: CleanViewTask[]
21 planSource: CleanViewPlanSource | null
22 needsYouReason: string | null
23 stuckReason: string | null
24 failedInARow: number
25 // The main turn working on this job right now; null between turns.
26 turnId: string | null
27 startedAt: number
28 finishedAt: number | null
29 isCollapsed: boolean
30}
31
32export type DockCardStatus = 'queued' | 'working' | 'done' | 'stuck'
33
34// One helper: an Agent call of the main loop, keyed by its tool_use_id.
35export type DockCard = {
36 id: string
37 task: string
38 initials: string
39 colorIndex: number
40 status: DockCardStatus
41 percent: number
42 hasReported: boolean
43 // The subagent's id once agent.spawn answered; its report_progress and
44 // turn.complete carry it.
45 agentId: string | null
46 // From agent.spawn; null when the spawn was never seen.
47 isBackground: boolean | null
48 startedAt: number | null
49 finishedAt: number | null
50}
51
52// One request split across helpers, from the person's prompt to the main
53// turn that ends after every helper has finished.
54export type DockMission = {
55 id: string
56 job: string
57 size: number
58 cards: DockCard[]
59 hasNudged: boolean
60 startedAt: number
61 finishedAt: number | null
62}
63
64export type DockHelperModel = 'fast' | 'same'
65
66declare module 'claude-code' {
67 interface PluginState {
68 'mods-toolbox': {
69 cleanViewEnabled: boolean
70 checklist: CleanViewChecklist | null
71 tick: number
72 dockTeamSize: number
73 dockHelperModel: DockHelperModel
74 // A size above the big-team line waiting for Continue or Cancel.
75 dockPendingSize: number | null
76 dockIsCustomOpen: boolean
77 dockIsFolded: boolean
78 dockMission: DockMission | null
79 dockTick: number
80 }
81 }
82}
83