SLOPSHOPPER

mods-toolbox

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…

newpanebandspinnerrowsguard
★ 10v?MITupdated 2026-10-09rube-de/cc-skills/plugins/mods-toolbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods-toolbox
│ ┃ Agent Dock ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ◆ A G E N T D O C K S T A N D I N G B Y │ mods-toolbox │ │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ Clean View is off: every detail is showing │ │ ┃ ──────────── ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ T E A M S I Z E ╭ 1 3 5 10 20 50 ⏺ Update(src/auth.ts) │ ┃ Claude decides how many helpers · 10 at a… ⎿ Added 2 lines, removed 1 line │ ┃ Helper agents Fast & Cheap Same as me ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ● │ ┃ Your team of 1 is standing by ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Send a request and Claude decides how many │ ┃ helpers to use. ✻ Worked for 42s · done 4:20 PM │ │ › /dock │ ⎿ mods-toolbox: Agent Dock toggled. Type /dock again to switch bac │ │ [ ○ Clean View: OFF ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ ◆ Dock

Draws

Band
[ ○ Clean View: OFF ] ⟨Claude Code's own drawing⟩
Pane · Agent Dock
◆ A G E N T D O C K S T A N D I N G B Y ──────────────────────────────────────────────────────── T E A M S I Z E ╭ 1 3 5 10 20 50 100 │ Custom Claude decides how many helpers · 10 at a time · Same m… Helper agents Fast & Cheap Same as me ● Your team of 1 is standing by Send a request and Claude decides how many helpers to use.
README

Mods Toolbox

Function-hook mods that change how Claude Code looks while it works:

  • Clean View: one calm checklist above the prompt instead of tool calls, diffs and command output.
  • Agent Dock: every request split across a team of parallel helpers you pick, each shown as a live card.

They ship together because the dock's helpers report through Clean View's report_progress tool.

Requirements

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.

Install

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.

Clean View

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.

Turn it on and off

Clean View starts on. Click [ ● Clean View: ON ] above the prompt, or type:

CommandEffect
/simpleFlip it
/simple onTurn it on
/simple offTurn 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.

What it changes

AreaBehaviour
Plan firstClaude 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.
ProgressClaude 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 nameWhile 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 rowsToolUse, ToolResult and ToolGroup rows, and the "run in background" hint.
StatesWorking, 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).

Agent Dock

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
CommandEffect
/dockOpen the dock, or fold it to a badge under the prompt (20 working · 10 queued · 20 done)
/dock 10Set the Team Size (1 to 100) and open the dock
◆ DockThe button at the right of the prompt footer opens it too
  • Size 1: nothing is added to your requests. Claude decides how many helpers to use, and skills that launch their own agents work as before. The dock still shows any helpers they launch.
  • Size above 1: each request carries an instruction to split the work into exactly N pieces, one helper per piece, all launched at once. A helper past N is refused. If Claude uses fewer than N, it gets one follow-up asking it to split the rest.
  • Not split: slash commands and skills (their helpers show in the dock but are never capped, held, moved to Haiku or nudged), and a request you send while helpers from the last one are still running (it joins their mission).
  • Big teams: a size above 20 asks you to confirm first, and a new session always starts back at 1.
  • Helper model: Fast & Cheap runs helpers on Haiku (only when the size is above 1 and the call names no model). Same as me leaves the model alone.
  • Queueing: Claude Code refuses an Agent call past 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.
  • Progress: helpers are asked to call 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.

For other mods

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.

Development

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.

Source 7 files
hooks/register.tsx 12 lines
1import 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}
12
hooks/clean-view.tsx 821 lines
1import { 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}
821
hooks/dock.tsx 950 lines
1import { 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}
950
hooks/clean-name.ts 48 lines
1export 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}
48
hooks/progress.ts 23 lines
1// 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}
23
hooks/dock-logic.ts 484 lines
1import 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}
484
types/index.d.ts 83 lines
1export 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