SLOPSHOPPER

crew

Agents side pane: every subagent as a little pixel crew member with its model, effort, context, tokens, cost and time; running, completed and planned tasks…

newpaneguardcommandtoasttimer
v0.1.0no licenseupdated 2026-10-04harshitmywork17/claude-mods/plugins/crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭────────────────╮ ╭────────────────╮ ╭───── ⎿ Read 6 lines │ ┃ │ Cost │ │ Tokens │ │ Time ⏺ Update(src/auth.ts) │ ┃ │ ≈$0.42 │ │ 0 │ │ – ⎿ Added 2 lines, removed 1 line │ ┃ ╰────────────────╯ ╰────────────────╯ ╰───── ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▄▀▀▀▀▄ No crew yet │ ┃ ▄▀▀▀▀▀▀▄Subagents appear here as Claude star ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▀▀▀▀▀ with their model, effort, context, t │ ┃ cost and time. ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Agents ╭────────────────╮ ╭────────────────╮ ╭────────────────╮ │ Cost │ │ Tokens │ │ Time │ │ ≈$0.42 │ │ 0 │ │ – │ ╰────────────────╯ ╰────────────────╯ ╰────────────────╯ ▄▀▀▀▀▄ No crew yet ▄▀▀▀▀▀▀▄Subagents appear here as Claude starts them, with th ▀▀▀▀▀▀ model, effort, context, tokens, cost and time.
README

harshit-mods

A Claude Code plugin marketplace, and the single home for Claude customizations that stay the same on every device: ten mods, the Claritymaxx skill, the sync-home skill, and the karpathy-guidelines and python-standards coding skills. To add something, ask Claude to put it "on all my devices". The sync-home skill and CLAUDE.md tell it how.

PluginCommandWhat it does
usage-forecast/forecast, /forecast hide, /forecast showA band above the prompt. 5-hour and Weekly plan limits as coloured bars (green, then yellow from 70%, red from 90%) with the percent used, a reset countdown and the reset time (↻ resets in 1h 12m · 15:40), and ⚠ at this pace, out in 40m when you would run out before the reset. Below it, the context window as weather with a 12-turn sparkline and turns left. Replaces token-weather.
blast-radius/blast-radius, /blast-radius <command> (dry run)Catches rm -rf, git reset --hard, force pushes, git clean -f, git checkout ., git branch -D, git stash drop/clear, DROP/TRUNCATE, mkfs, dd of=/dev/.... It shows what the command would touch in a pane and forces a permission prompt. A deny from your settings is never loosened.
replay-theater/replay, /replay 2Records each turn's Edit and Write calls. In the pane, n/p step through the diffs, o/w change turn, and Esc closes it.
changed-files/changed-filesA live sidebar of every file touched this session, with +/- counts, edit counts and a new tag. It opens by itself on the first edit.
session-meter/meterThe status line shows elapsed time, tool calls and files edited. A toast appears when a turn takes 60 s or more; change it with /plugin configure session-meter@harshit-mods.
claritymaxx/claritymaxx:explain <topic>, or ask "explain..."Third-party skill from v60samurai/claritymaxx. Builds a mental model first, then explains it as text, a diagram or a small HTML page.
fork-explorer/fork <what if...>, or type in the paneRuns a side question over the current session and shows 2-3 alternative approaches as cards side by side, with pros, cons and effort. "use this" puts the choice in your prompt box to edit and send. Nothing is added to your conversation.
mission-control/missionA live pane of what Claude is executing: each tool call with its input, status badge (✓ ✗ ⊘ ●), duration and output; subagents in their own lanes with tool and token counts; a network-tab style waterfall; time spent in the model between tools. v switches between the timeline and the outputs. Observe-only; it never changes a call.
diff-minimapautomaticA thin strip beside each Edit and Write row in the transcript. Green, red and yellow ticks show where in the file the change sits, with the line range and the file length.
codebase-atlas/atlas, /atlas changes, /atlas components <file>, /atlas calls <name>, /atlas decisions, /atlas rescanOne pane with six tabs. Map: folders as boxes in dependency layers (entry points on top, foundations at the bottom), lit yellow when Claude edits and cyan when it reads; select one for what it imports and what uses it. Changes: every uncommitted change mapped to the functions and classes it added, modified or removed, with call-site counts, the layers crossed and the files that import what changed. Components: a file's classes, functions and methods with size bars, what each calls, and which changed. Calls: callers and callees of any function; type a name or select one in the transcript, then walk the graph. Decisions: a timeline of decisions a small model pulls from each turn (turn off with extractDecisions). Explain: side-question explanations of a folder, a function or the session's changes that never enter your conversation. Works with or without git: in a git repo, changes are measured against the last commit; in a plain folder, against the files as Atlas first read them that session (dependencies, virtual environments and build output are skipped). Reads Python and JS/TS.
workbench/wb, /wb changes [file], /wb preview <file>, /wb artifacts, /wb map, /wb usage, /wb calls <name>, /wb forecast, /wb workbench, /wb hud offOne workspace. A one-line band above the prompt with two slides, switched with ◀ ▶: Workbench (session time, tool calls, files changed with +/−, new files, plan limits and context on wide terminals, then the running tool or the last edit and its functions) and Forecast (5-hour and weekly limits with bars, reset times and pace, plus the context forecast; it shows the usage-forecast mod's band when that mod is on). A pane with six tabs on one row: Now, the turn's tool calls with status and timing, failures with their error, and subagents nested under the call that started them; Changes, every file with a change bar and the functions it touched, then a file's changed functions and highlighted diff since Claude first touched it, then each edit (replay with n/p); Preview, a live render of the file being edited (Markdown, CSV table, JSON tree, HTML screenshot, PNG, code) with recent files one press away; Artifacts, files created this session by Claude ✦ or its commands, with preview and copy path; Map, architecture layers with folder sizes and imports, a file's components, and the call graph; Usage, limits with reset times and pace, context, and cost per turn. Works with or without git. Claude Code's own diff panel sits above mod panes: run /diff to hide it if it covers the workbench.
crew/crew, /crew auto off, /crew closeAn Agents side pane, like a mission board for subagents. Three cards on top total the session's cost, tokens and time. Each subagent is a little pixel crew member whose hat shows its effort (heavy, careful, medium, light), with its model and effort level, context used with a bar, tokens, its share of the cost and how long it ran; it walks while it works and shows the tool it is running. Sections: Running, Completed (✓ done, ✗ failed, ⊘ stopped; folds) and Planned, the tasks from Claude's task or todo list with what each still waits for ("after 1, 3"). Click an agent for its task, tool count and answer. Opens by itself when the first subagent starts; /crew auto off stops that.
explainer-page/explainer-page:explainer-page, or ask for a visual or interactive explainer pageA skill for textbook-style HTML pages that teach one concept: each idea in words, maths, a figure and code, with colour-coded notation (inputs blue, outputs orange, parameters green, functions violet, probabilities pink) kept identical everywhere; tables and figures (3D bars, node diagrams) drawn from one data source and highlighted together on hover; sliders where a parameter is the point; code cells with their real output; margin notes, callouts and check-yourself questions; light and dark themes and a contents sidebar. Publishes as a claude.ai artifact when that tool is available, otherwise writes an .html file. Triggers only on an explicit request for such a page.
savvy-flow/savvy-flow:savvy-flow <task>A copy of savvy-flow from johnnyvizz/claude-kit with one change: the top tier, savvy-fable, runs on Opus at max effort instead of the Fable model, so it works without Fable access. The session model plans the task, delegates pieces to five tiered worker agents (savvy-fable, savvy-heavy, savvy-careful, savvy-medium, savvy-light) and reviews what they return. tools/sync-savvy-flow.sh refreshes the copy from upstream and reapplies the change; the Sync savvy-flow GitHub Action runs it daily and commits when upstream changed.
savvy-progress/agents-infoFrom the same repo, following its main branch the same way. A progress bar above the prompt driven by savvy-flow, and an agents panel with model, effort, step progress, context, estimated cost and time. Animated crabs draw in the desktop app; the terminal shows text rows (crew draws its sprites in both).
sync-home(automatic) or ask "add X to all my devices"A skill that tells Claude this repository is where plugins, mods, skills and other customizations go so they sync to every device. Claude adds them under plugins/, validates, pushes and tells you what to turn on.
karpathy-guidelines/karpathy-guidelines:clean-code, or automatic on any coding taskGuidelines from Andrej Karpathy's notes on LLM coding mistakes: think before coding, simplicity first, surgical changes, goal-driven execution, with worked examples in EXAMPLES.md.
python-standards/python-standards:python-standards, or automatic on Python workPython standards: SOLID, DRY, KISS and YAGNI; FastAPI project layout; naming; OWASP-aligned secure coding; linting and testing setup.

Use them on every device

Add this marketplace to your claude.ai account once:

  1. On claude.ai, open Customize, then Plugins, then Add, then Add marketplace.
  2. Enter harshitmywork17/claude-mods. If the repository is private, your GitHub account must be connected to Claude.
  3. Turn on each plugin. Turn on sync-home too, so every session knows to put new synced customizations here.

Plugins on your account sync at session start, on every machine where you sign in to Claude Code with that account. Mods need Claude Code v2.1.287 or later (claude --version, then claude update). After you push a change here, press Check for updates on the marketplace in claude.ai, and turn on any new plugin. Then start a new session.

If a command such as /wb is missing on a machine:

  1. claude --version must be 2.1.287 or later.
  2. claude plugin list should show workbench@synced. If it doesn't, the sync hasn't picked it up: check for updates in claude.ai, or install on that machine as below.
  3. claude plugin test run in an empty folder should say no hooks module to load. Any other message means mods are turned off there.

Mods run in Claude Code only: the terminal, the desktop app's Code tab, IDE extensions and cloud sessions set up as below. Claude chat on the web, desktop and mobile shows no mod UI. Claritymaxx is a skill, so it also works in Claude chat and Cowork.

Use them in cloud sessions

Cloud sessions at claude.ai/code don't load account plugins or the plugins a repository lists in .claude/settings.json. They do load plugin folders named in CLAUDE_CODE_PLUGIN_DIRS. Set it once on the cloud environment:

  1. Open the environment menu in a session's title bar, then Edit.
  2. Under Environment variables, add: `` CLAUDE_CODE_PLUGIN_DIRS=/home/user/claude-mods/plugins ``
  3. When you start a cloud session, select harshitmywork17/claude-mods alongside the repository you're working on. It is cloned to /home/user/claude-mods, and every plugin in plugins/ loads, including new ones.

A cloud session can only clone the repositories selected for it, because this repository is private. In a session without it, the variable points at a missing folder, which Claude Code skips.

Install on one machine

claude plugin marketplace add harshitmywork17/claude-mods
claude plugin install workbench@harshit-mods   # repeat for each plugin

Or run every plugin from a local clone, picking up changes with git pull: add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "<path to clone>/plugins" } to ~/.claude/settings.json. Don't combine this with account sync on the same machine: when two copies share a name, only the first one loads.

Develop

Each mod has tests: claude plugin test plugins/<mod>. Check a mod with claude plugin validate plugins/<mod>. Increase version in a mod's plugin.json when you change it, because installed copies are cached by version.

Source 3 files
hooks/register.tsx 527 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderNode, Register } from 'claude-code'
3
4import type { CrewAgent, PlanTask } from '../types'
5import { C, applyTask, clock, firstLine, head, modelLabel, money, sprite, statusOf, tierOf, tokens, waitingOn } from './crew'
6import type { Cell, TaskEvent, Tier } from './crew'
7
8// ── State ───────────────────────────────────────────────────────────────
9
10const agentsAtom = atom({ plugin: 'crew', key: 'agents' } as const, [])
11const tasksAtom = atom({ plugin: 'crew', key: 'tasks' } as const, [])
12const titleAtom = atom({ plugin: 'crew', key: 'sessionTitle' } as const, '')
13const startedAtAtom = atom({ plugin: 'crew', key: 'startedAt' } as const, 0)
14const totalTokensAtom = atom({ plugin: 'crew', key: 'totalTokens' } as const, 0)
15const costAtom = atom({ plugin: 'crew', key: 'costUsd' } as const, null)
16const windowAtom = atom({ plugin: 'crew', key: 'contextWindow' } as const, 200_000)
17const completedOpenAtom = atom({ plugin: 'crew', key: 'isCompletedOpen' } as const, true)
18const plannedOpenAtom = atom({ plugin: 'crew', key: 'isPlannedOpen' } as const, true)
19const expandedAtom = atom({ plugin: 'crew', key: 'expanded' } as const, null)
20
21const PANE = 'crew'
22const TITLE = 'Agents'
23const AUTO_OPEN_KEY = 'autoOpen'
24const SPRITE_WIDTH = 8
25
26/** The session cost when it was last read: each request is charged the growth since. */
27let lastCostUsd: number | null = null
28let hasAutoOpened = false
29
30// ── Recording ───────────────────────────────────────────────────────────
31
32async function changeAgent($: EngineInterface, id: string, change: (agent: CrewAgent) => CrewAgent): Promise<void> {
33  await update($, agentsAtom, (list): CrewAgent[] => list.map(agent => (agent.id === id ? change(agent) : agent)))
34}
35
36/** The session's cost now, and how much it grew since the last read. */
37async function costGrowth($: EngineInterface): Promise<number> {
38  const usd = (await $.session.usage()).cost?.usd
39  if (usd === undefined) return 0
40  const grown = lastCostUsd === null ? 0 : Math.max(0, usd - lastCostUsd)
41  lastCostUsd = usd
42  await update($, costAtom, () => usd)
43  return grown
44}
45
46/** Folds the engine's list of agents in: statuses it settled, and agents started before this mod loaded. */
47async function syncList($: EngineInterface): Promise<void> {
48  const listed = await $.agent.list()
49  const now = await $.clock.now()
50  await update($, agentsAtom, (list): CrewAgent[] => {
51    const next = list.map(agent => {
52      const info = listed.find(one => one.id === agent.id)
53      if (info === undefined || agent.status !== 'running') return agent
54      const status = statusOf(info.status)
55      return status === 'running' ? agent : { ...agent, status, endedAt: agent.endedAt ?? now, current: undefined }
56    })
57    for (const info of listed) {
58      if (next.some(agent => agent.id === info.id)) continue
59      next.push({
60        id: info.id,
61        n: next.length + 1,
62        title: info.description || info.name || info.type,
63        type: info.type,
64        prompt: '',
65        status: statusOf(info.status),
66        startedAt: now,
67        tokens: 0,
68        context: 0,
69        costUsd: 0,
70        tools: 0,
71        parentId: info.parentId,
72      })
73    }
74    return next
75  })
76}
77
78function toolSummary(tool: string, input: Record<string, unknown>): string {
79  const text = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
80  if (tool === 'Read' || tool === 'Edit' || tool === 'Write') return text('file_path').split('/').pop() ?? ''
81  if (tool === 'Bash') return text('command')
82  if (tool === 'Grep' || tool === 'Glob') return text('pattern')
83  if (tool === 'WebFetch') return text('url')
84  if (tool === 'WebSearch') return text('query')
85  const first = Object.values(input).find(value => typeof value === 'string')
86  return typeof first === 'string' ? first : ''
87}
88
89/** What a task-list or todo tool call did to the plan, from its input and result. */
90function taskEvent(tool: string, input: Record<string, unknown>, result: unknown): TaskEvent | null {
91  const record = (result ?? {}) as Record<string, unknown>
92  if (tool === 'TaskCreate') {
93    const task = record.task as { id?: unknown; subject?: unknown } | undefined
94    if (typeof task?.id !== 'string') return null
95    return { kind: 'create', id: task.id, subject: typeof task.subject === 'string' ? task.subject : String(input.subject ?? '') }
96  }
97  if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
98    return {
99      kind: 'update',
100      id: input.taskId,
101      subject: typeof input.subject === 'string' ? input.subject : undefined,
102      status: typeof input.status === 'string' ? input.status : undefined,
103      addBlockedBy: Array.isArray(input.addBlockedBy) ? input.addBlockedBy.filter((id): id is string => typeof id === 'string') : undefined,
104      owner: typeof input.owner === 'string' ? input.owner : undefined,
105    }
106  }
107  if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
108    const todos = input.todos
109      .filter((todo): todo is { content: string; status: string } => typeof todo === 'object' && todo !== null && typeof (todo as { content?: unknown }).content === 'string')
110      .map(todo => ({ content: todo.content, status: String(todo.status) }))
111    return { kind: 'todos', todos }
112  }
113  return null
114}
115
116async function openPane($: EngineInterface, isAsked: boolean): Promise<void> {
117  const opened = await $.ui.open(isAsked ? { id: PANE, title: TITLE, focus: true } : { id: PANE, title: TITLE })
118  if (isAsked && !opened.isPlaced) $.ui.toast(`Crew is waiting for room: ${opened.reason}`)
119}
120
121// ── Drawing ─────────────────────────────────────────────────────────────
122
123type T = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
124
125const STATUS_MARK = {
126  running: ['●', C.live],
127  completed: ['✓', C.ok],
128  failed: ['✗', C.bad],
129  stopped: ['⊘', C.warn],
130} as const
131
132function spriteView(t: T, rows: Cell[][]): RenderNode {
133  const { Box, Text } = t
134  return (
135    <Box flexDirection="column" width={SPRITE_WIDTH} flexShrink={0}>
136      {rows.map((cells, row) => (
137        <Text key={`sprite-row-${row}`}>
138          {cells.map((cell, index) => (
139            <Text key={`sprite-${row}-${index}`} color={cell.fg} backgroundColor={cell.bg}>
140              {cell.char}
141            </Text>
142          ))}
143        </Text>
144      ))}
145    </Box>
146  )
147}
148
149function bar(t: T, share: number, width: number, color: string): RenderNode {
150  const { Text } = t
151  const filled = Math.max(share > 0 ? 1 : 0, Math.min(width, Math.round(share * width)))
152  return (
153    <Text>
154      <Text color={color}>{'━'.repeat(filled)}</Text>
155      <Text color={C.track}>{'━'.repeat(width - filled)}</Text>
156    </Text>
157  )
158}
159
160function card(t: T, label: string, value: string, width: number): RenderNode {
161  const { Box, Text } = t
162  return (
163    <Box key={`card-${label}`} flexDirection="column" width={width} flexShrink={0} borderStyle="round" borderColor={C.track} paddingX={1}>
164      <Text color={C.soft}>{label}</Text>
165      <Text bold color={C.text}>
166        {value}
167      </Text>
168    </Box>
169  )
170}
171
172function agentRow($: EngineInterface, t: T, agent: CrewAgent, window: number, now: number, frame: number, isOpen: boolean, width: number): RenderNode {
173  const { Box, Button, Text } = t
174  const tier: Tier = tierOf(agent.effort)
175  const textWidth = Math.max(16, width - SPRITE_WIDTH - 2 - 3)
176  const share = window > 0 ? agent.context / window : 0
177  const elapsed = (agent.endedAt ?? now) - agent.startedAt
178  const [mark, markColor] = STATUS_MARK[agent.status]
179  const stats = `ctx ${Math.round(share * 100)}% · ${tokens(agent.tokens)} ${money(agent.costUsd)} ${clock(elapsed)}`
180  return (
181    <Box key={`agent-${agent.id}`} flexDirection="column">
182      <Box flexDirection="row" marginTop={1}>
183        {spriteView(t, sprite(tier, agent.status, frame))}
184        <Box width={2} flexShrink={0} />
185        <Box flexDirection="column" width={textWidth} flexShrink={0}>
186          <Button key={`open:${agent.id}`} label={head(`${agent.n}. ${agent.title}`, textWidth)} plain onPress={() => update($, expandedAtom, now => (now === agent.id ? null : agent.id))} />
187          <Text wrap="truncate-end">
188            <Text color={tier.color}>{tier.label}</Text>
189            <Text color={C.soft}>
190              {' '}
191              {modelLabel(agent.model)}
192              {agent.effort === undefined ? '' : ` · ${agent.effort}`}
193            </Text>
194          </Text>
195          <Text color={C.soft} wrap="truncate-end">
196            {stats}
197          </Text>
198          {bar(t, share, Math.max(8, textWidth - 4), tier.color)}
199          {agent.status === 'running' && agent.current !== undefined && (
200            <Text color={C.live} wrap="truncate-end">
201              ▸ {agent.current}
202            </Text>
203          )}
204        </Box>
205        <Box width={3} flexShrink={0} justifyContent="flex-end">
206          <Text bold color={agent.status === 'running' ? (frame % 2 === 0 ? tier.color : C.line) : markColor}>
207            {mark}
208          </Text>
209        </Box>
210      </Box>
211      {isOpen && (
212        <Box flexDirection="column" paddingLeft={SPRITE_WIDTH + 2} marginTop={1}>
213          <Text color={C.soft} wrap="truncate-end">
214            {agent.type} · {agent.tools} tool call{agent.tools === 1 ? '' : 's'} · {agent.id}
215          </Text>
216          {agent.prompt !== '' && (
217            <Text color={C.line} wrap="wrap">
218              {head(agent.prompt, 300)}
219            </Text>
220          )}
221          {agent.answer !== undefined && (
222            <Text color={agent.status === 'failed' ? C.bad : C.text} wrap="wrap">
223              ↳ {agent.answer}
224            </Text>
225          )}
226        </Box>
227      )}
228      <Text color={C.track}>{'─'.repeat(width)}</Text>
229    </Box>
230  )
231}
232
233function planRow(t: T, task: PlanTask, tasks: readonly PlanTask[], width: number): RenderNode {
234  const { Box, Text } = t
235  const waits = waitingOn(task, tasks)
236  const textWidth = Math.max(16, width - SPRITE_WIDTH - 2 - 3)
237  const label = task.id.startsWith('todo-') ? task.id.slice(5) : task.id
238  return (
239    <Box key={`plan-${task.id}`} flexDirection="column">
240      <Box flexDirection="row" marginTop={1}>
241        {spriteView(t, sprite(tierOf(undefined), 'planned', 0))}
242        <Box width={2} flexShrink={0} />
243        <Box flexDirection="column" width={textWidth} flexShrink={0}>
244          <Text bold color={C.text} wrap="truncate-end">
245            {label}. {task.subject}
246          </Text>
247          <Text wrap="truncate-end">
248            {task.status === 'in_progress' ? <Text color={C.live}>in progress</Text> : waits.length === 0 ? <Text color={C.ok}>ready</Text> : <Text color={C.warn}>waiting</Text>}
249            <Text color={C.soft}>
250              {waits.length > 0 ? ` · after ${waits.join(', ')}` : ''}
251              {task.owner === undefined ? '' : ` · ${task.owner}`}
252            </Text>
253          </Text>
254        </Box>
255        <Box width={3} flexShrink={0} justifyContent="flex-end">
256          <Text color={task.status === 'in_progress' ? C.live : C.soft}>{task.status === 'in_progress' ? '◐' : '◷'}</Text>
257        </Box>
258      </Box>
259      <Text color={C.track}>{'─'.repeat(width)}</Text>
260    </Box>
261  )
262}
263
264function sectionTitle(t: T, label: string, count: number): RenderNode {
265  const { Text } = t
266  return (
267    <Text key={`section-${label}`} color={C.text}>
268      {label} <Text color={C.soft}>· {count}</Text>
269    </Text>
270  )
271}
272
273// ── Hooks ───────────────────────────────────────────────────────────────
274
275export const register: Register = on => {
276  on('session.start', async ($, e, next) => {
277    await $.command.register({ name: 'crew', description: 'Agents pane: every subagent with model, effort, context, tokens, cost and time', argumentHint: '[auto on|off|close]', immediate: true })
278    $.clock.after(0, () => {
279      void (async () => {
280        const usage = await $.session.usage()
281        await update($, startedAtAtom, () => usage.startedAt)
282        await update($, windowAtom, () => usage.context.window)
283        lastCostUsd = usage.cost?.usd ?? null
284        await update($, costAtom, () => usage.cost?.usd ?? null)
285        await syncList($)
286      })().catch(() => undefined)
287    })
288    let ticks = 0
289    $.clock.every(1000, () => {
290      ticks += 1
291      void (async () => {
292        const isBusy = (await read($, agentsAtom)).some(agent => agent.status === 'running')
293        if (!isBusy) return
294        $.ui.invalidate('ui.render')
295        if (ticks % 5 === 0) await syncList($)
296      })().catch(() => undefined)
297    })
298    return next(e)
299  })
300
301  on('turn.start', async ($, e, next) => {
302    const text = e.text.trim()
303    if (text !== '' && !text.startsWith('<') && (await read($, titleAtom)) === '') await update($, titleAtom, () => firstLine(text, 60))
304    return next(e)
305  })
306
307  on('agent.spawn', async ($, e, next) => {
308    const started = await next(e)
309    if (started.agentId === undefined) return started
310    try {
311      const id = started.agentId
312      const now = await $.clock.now()
313      await update($, agentsAtom, (list): CrewAgent[] =>
314        list.some(agent => agent.id === id)
315          ? list
316          : [
317              ...list,
318              {
319                id,
320                n: list.length + 1,
321                title: firstLine(e.description || e.name || e.subagentType, 80),
322                type: e.subagentType,
323                prompt: firstLine(e.prompt, 400),
324                model: started.model,
325                status: 'running',
326                startedAt: now,
327                tokens: 0,
328                context: 0,
329                costUsd: 0,
330                tools: 0,
331                parentId: e.parentAgentId,
332              },
333            ],
334      )
335      if (!hasAutoOpened && (await $.store.get(AUTO_OPEN_KEY)) !== false) {
336        hasAutoOpened = true
337        await openPane($, false)
338      }
339    } catch {
340      // Watching is best effort: the spawn's own result always goes back unchanged.
341    }
342    return started
343  })
344
345  on('turn.step', async function* ($, e, next) {
346    const answered = yield* next(e)
347    try {
348      const usage = answered.usage
349      if (usage !== null) {
350        const context = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
351        const spent = context + usage.output_tokens
352        await update($, totalTokensAtom, total => total + spent)
353        const grown = await costGrowth($)
354        const id = e.agentId
355        if (id !== undefined) {
356          await changeAgent($, id, agent => ({
357            ...agent,
358            model: e.model || agent.model,
359            effort: e.effort === undefined ? agent.effort : String(e.effort),
360            tokens: agent.tokens + spent,
361            context,
362            costUsd: agent.costUsd + grown,
363          }))
364        }
365      }
366    } catch {
367      // The step's stream and result pass through untouched whatever happens here.
368    }
369    return answered
370  })
371
372  on('tool.call', async ($, e, next) => {
373    const input = e as unknown as Record<string, unknown>
374    const tool = String(e.tool)
375    const id = e.agentId
376    if (id !== undefined) {
377      await changeAgent($, id, agent => ({ ...agent, tools: agent.tools + 1, current: firstLine(`${tool} ${toolSummary(tool, input)}`, 120) })).catch(() => undefined)
378    }
379    const ran = await next(e)
380    try {
381      if (id !== undefined) await changeAgent($, id, agent => ({ ...agent, current: undefined }))
382      if (ran.deny === undefined && ran.isError !== true) {
383        const event = taskEvent(tool, input, ran.result)
384        if (event !== null) await update($, tasksAtom, (tasks): PlanTask[] => applyTask(tasks, event))
385      }
386    } catch {
387      // The tool's own result always goes back unchanged.
388    }
389    return ran
390  })
391
392  on('turn.complete', async ($, e, next) => {
393    const ran = await next(e)
394    const id = e.agentId
395    if (id === undefined) return ran
396    try {
397      const now = await $.clock.now()
398      const status = statusOf('', e.reason)
399      const why = e.reason === 'refusal' ? e.refusal.explanation : e.answer
400      await changeAgent($, id, agent => ({ ...agent, status, endedAt: now, current: undefined, answer: firstLine(why ?? '', 240) || undefined }))
401    } catch {
402      // Best effort.
403    }
404    return ran
405  })
406
407  on('session.measure', async ($, e, next) => {
408    if (e.changed.includes('cost') && e.cost !== undefined) {
409      const usd = e.cost.usd
410      await update($, costAtom, () => usd)
411    }
412    if (e.changed.includes('context')) {
413      const window = e.context.window
414      await update($, windowAtom, () => window)
415    }
416    return next(e)
417  })
418
419  on('session.end', async ($, e, next) => {
420    if (e.reason === 'clear') {
421      await update($, agentsAtom, (): CrewAgent[] => [])
422      await update($, tasksAtom, (): PlanTask[] => [])
423      await update($, titleAtom, () => '')
424      await update($, totalTokensAtom, () => 0)
425      await update($, expandedAtom, () => null)
426    }
427    return next(e)
428  })
429
430  on('command.run', { command: 'crew' }, async ($, e) => {
431    const [word = '', value = ''] = e.args.trim().split(/\s+/)
432    if (word === 'auto') {
433      await $.store.set(AUTO_OPEN_KEY, value !== 'off')
434      return { text: value === 'off' ? 'Crew no longer opens by itself when a subagent starts.' : 'Crew opens by itself when the first subagent starts.' }
435    }
436    if (word === 'close') {
437      await $.ui.close({ id: PANE })
438      return {}
439    }
440    await syncList($)
441    await openPane($, true)
442    return {}
443  })
444
445  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
446    const t: T = $.ui.resolve(e)
447    const { Box, Button, Text } = t
448    const width = Math.max(40, e.props.bodyColumns)
449    const now = await $.clock.now()
450    const frame = Math.floor(now / 600)
451    const agents = await read($, agentsAtom)
452    const tasks = await read($, tasksAtom)
453    const window = await read($, windowAtom)
454    const expanded = await read($, expandedAtom)
455    const isCompletedOpen = await read($, completedOpenAtom)
456    const isPlannedOpen = await read($, plannedOpenAtom)
457    const startedAt = await read($, startedAtAtom)
458    const cost = await read($, costAtom)
459    const title = await read($, titleAtom)
460
461    const running = agents.filter(agent => agent.status === 'running')
462    const done = agents.filter(agent => agent.status !== 'running').reverse()
463    const planned = tasks.filter(task => task.status !== 'completed')
464    const cardWidth = Math.floor((width - 2) / 3)
465    const isAllOpen = isCompletedOpen && isPlannedOpen
466    const row = (agent: CrewAgent): RenderNode => agentRow($, t, agent, window, now, frame, expanded === agent.id, width)
467
468    return (
469      <Box flexDirection="column">
470        <Text bold color={C.text} wrap="truncate-end">
471          Agents{title === '' ? '' : ` · ${title}`}
472        </Text>
473        <Box flexDirection="row" columnGap={1} marginTop={1}>
474          {card(t, 'Cost', cost === null ? '–' : money(cost), cardWidth)}
475          {card(t, 'Tokens', tokens(await read($, totalTokensAtom)), cardWidth)}
476          {card(t, 'Time', startedAt > 0 ? clock(now - startedAt) : '–', cardWidth)}
477        </Box>
478        {agents.length === 0 && planned.length === 0 ? (
479          <Box flexDirection="row" marginTop={1}>
480            {spriteView(t, sprite(tierOf(undefined), 'planned', 0))}
481            <Box width={2} flexShrink={0} />
482            <Box flexDirection="column">
483              <Text bold color={C.soft}>
484                No crew yet
485              </Text>
486              <Text color={C.line}>Subagents appear here as Claude starts them, with their model, effort, context, tokens, cost and time.</Text>
487            </Box>
488          </Box>
489        ) : (
490          <Box flexDirection="column">
491            <Box marginTop={1}>
492              <Button
493                key="toggle-all"
494                label={isAllOpen ? 'Collapse' : 'Expand'}
495                plain
496                dimColor
497                onPress={async () => {
498                  await update($, completedOpenAtom, () => !isAllOpen)
499                  await update($, plannedOpenAtom, () => !isAllOpen)
500                }}
501              />
502            </Box>
503            {running.length > 0 && (
504              <Box flexDirection="column" marginTop={1}>
505                {sectionTitle(t, 'Running', running.length)}
506                {running.map(row)}
507              </Box>
508            )}
509            {done.length > 0 && (
510              <Box flexDirection="column" marginTop={1}>
511                <Button key="toggle-completed" label={`${isCompletedOpen ? '▾' : '▸'} Completed · ${done.length}`} plain onPress={() => update($, completedOpenAtom, value => !value)} />
512                {isCompletedOpen && done.map(row)}
513              </Box>
514            )}
515            {planned.length > 0 && (
516              <Box flexDirection="column" marginTop={1}>
517                <Button key="toggle-planned" label={`${isPlannedOpen ? '▾' : '▸'} Planned · ${planned.length}`} plain onPress={() => update($, plannedOpenAtom, value => !value)} />
518                {isPlannedOpen && planned.map(task => planRow(t, task, tasks, width))}
519              </Box>
520            )}
521          </Box>
522        )}
523      </Box>
524    )
525  })
526}
527
hooks/crew.ts 190 lines
1import type { AgentStatus, PlanTask } from '../types'
2
3export const C = {
4  ok: '#34D399',
5  bad: '#F87171',
6  warn: '#FBBF24',
7  live: '#22D3EE',
8  accent: '#A78BFA',
9  blue: '#60A5FA',
10  line: '#475569',
11  soft: '#94A3B8',
12  text: '#E2E8F0',
13  card: '#1E293B',
14  track: '#334155',
15} as const
16
17// ── Effort tiers ────────────────────────────────────────────────────────
18
19export type Tier = { label: string; color: string; hat: string; band?: string }
20
21const TIERS: Record<string, Tier> = {
22  heavy: { label: 'heavy', color: '#F87171', hat: '#A0522D', band: '#60A5FA' },
23  careful: { label: 'careful', color: '#F59E0B', hat: '#F2C94C' },
24  medium: { label: 'medium', color: '#60A5FA', hat: '#9CA3AF', band: '#3B82F6' },
25  light: { label: 'light', color: '#34D399', hat: '#22A06B' },
26  unknown: { label: 'agent', color: '#94A3B8', hat: '#64748B' },
27}
28
29/** How hard an agent thinks, as a crew rank: max and xhigh are heavy, high careful, medium, low light. */
30export function tierOf(effort: string | undefined): Tier {
31  if (effort === undefined) return TIERS.unknown as Tier
32  const budget = Number(effort)
33  if (!Number.isNaN(budget)) return (budget >= 32_000 ? TIERS.heavy : budget >= 12_000 ? TIERS.careful : budget >= 4_000 ? TIERS.medium : TIERS.light) as Tier
34  if (effort === 'max' || effort === 'xhigh') return TIERS.heavy as Tier
35  if (effort === 'high') return TIERS.careful as Tier
36  if (effort === 'medium') return TIERS.medium as Tier
37  if (effort === 'low') return TIERS.light as Tier
38  return TIERS.unknown as Tier
39}
40
41/** "claude-opus-5-5" reads "Opus 5.5", "claude-haiku-4-5-20251001" "Haiku 4.5", an alias "opus" "Opus". */
42export function modelLabel(model: string | undefined): string {
43  if (model === undefined || model === '') return 'model ?'
44  const id = model.replace(/\[.*\]$/, '')
45  const match = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(id)
46  const cap = (word: string): string => word.charAt(0).toUpperCase() + word.slice(1)
47  if (match !== null) return `${cap(match[1] ?? '')} ${match[2]}${match[3] === undefined ? '' : `.${match[3]}`}`
48  return /^[a-z]+$/.test(id) ? cap(id) : id
49}
50
51// ── Numbers ─────────────────────────────────────────────────────────────
52
53export function tokens(count: number): string {
54  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
55  if (count >= 1000) return `${Math.round(count / 1000)}k`
56  return String(Math.round(count))
57}
58
59export function money(usd: number): string {
60  return usd >= 10 ? `≈$${usd.toFixed(1)}` : `≈$${usd.toFixed(2)}`
61}
62
63/** A stopwatch: 3:21, 17:45, 1:02:09. */
64export function clock(ms: number): string {
65  const total = Math.max(0, Math.floor(ms / 1000))
66  const hours = Math.floor(total / 3600)
67  const minutes = Math.floor((total % 3600) / 60)
68  const seconds = String(total % 60).padStart(2, '0')
69  return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`
70}
71
72export function head(text: string, width: number): string {
73  if (width <= 1) return text.slice(0, Math.max(0, width))
74  return text.length <= width ? text : `${text.slice(0, width - 1)}…`
75}
76
77export function firstLine(text: string, width = 160): string {
78  return head(text.replace(/\s+/g, ' ').trim(), width)
79}
80
81/** The engine's task statuses folded into the pane's four. */
82export function statusOf(engine: string, reason?: string): AgentStatus {
83  if (reason === 'aborted' || engine === 'killed' || engine === 'stopped') return 'stopped'
84  if (reason === 'error' || reason === 'refusal' || engine === 'failed') return 'failed'
85  if (reason !== undefined || engine === 'completed') return 'completed'
86  return 'running'
87}
88
89// ── Plan ────────────────────────────────────────────────────────────────
90
91export type TaskEvent =
92  | { kind: 'create'; id: string; subject: string }
93  | { kind: 'update'; id: string; subject?: string; status?: string; addBlockedBy?: readonly string[]; owner?: string }
94  | { kind: 'todos'; todos: readonly { content: string; status: string }[] }
95
96/** Folds one TaskCreate, TaskUpdate or TodoWrite into the plan. */
97export function applyTask(tasks: readonly PlanTask[], event: TaskEvent): PlanTask[] {
98  if (event.kind === 'todos') {
99    // A todo list replaces itself whole; it lives beside task-list tasks under ids of its own.
100    const kept = tasks.filter(task => !task.id.startsWith('todo-'))
101    const todos = event.todos.map((todo, index): PlanTask => ({
102      id: `todo-${index + 1}`,
103      subject: todo.content,
104      status: todo.status === 'completed' ? 'completed' : todo.status === 'in_progress' ? 'in_progress' : 'pending',
105      blockedBy: [],
106    }))
107    return [...kept, ...todos]
108  }
109  if (event.kind === 'create') {
110    if (tasks.some(task => task.id === event.id)) return [...tasks]
111    return [...tasks, { id: event.id, subject: event.subject, status: 'pending', blockedBy: [] }]
112  }
113  if (event.status === 'deleted') return tasks.filter(task => task.id !== event.id)
114  return tasks.map(task =>
115    task.id !== event.id
116      ? task
117      : {
118          ...task,
119          subject: event.subject ?? task.subject,
120          status: event.status === 'pending' || event.status === 'in_progress' || event.status === 'completed' ? event.status : task.status,
121          blockedBy: [...new Set([...task.blockedBy, ...(event.addBlockedBy ?? [])])],
122          owner: event.owner ?? task.owner,
123        },
124  )
125}
126
127/** The tasks a task still waits for: its blockers not yet completed. */
128export function waitingOn(task: PlanTask, tasks: readonly PlanTask[]): string[] {
129  return task.blockedBy.filter(id => tasks.find(other => other.id === id)?.status !== 'completed')
130}
131
132// ── The crew member ─────────────────────────────────────────────────────
133
134/**
135 * The sprite, 8 pixels wide and 6 tall, two pixel rows to a terminal row:
136 * H hat, b hat band, B body, E eye, L leg, F the flag of a light agent, A a heavy agent's antenna.
137 */
138const BODY = ['..HHHH.A', '.HbbbbH.', '.BBBBBB.', 'BBEBBEBB', '.BBBBBB.']
139const LEGS = ['.L.LL.L.', 'L.L..L.L']
140const FLAGGED = ['..HHHH.F', '.HHHHHH.', '.BBBBBB.', 'BBEBBEBB', '.BBBBBB.']
141
142export type Cell = { char: string; fg?: string; bg?: string }
143
144const BODY_COLOR: Record<AgentStatus | 'planned', string> = {
145  running: '#E8795A',
146  completed: '#E8795A',
147  failed: '#B45F4D',
148  stopped: '#8C6A5F',
149  planned: '#7A4535',
150}
151
152/** The sprite as rows of half-block cells, each run of one colour pair merged; `frame` swaps the legs while it walks. */
153export function sprite(tier: Tier, status: AgentStatus | 'planned', frame: number): Cell[][] {
154  const body = BODY_COLOR[status]
155  const isDim = status === 'planned'
156  const shade = (hex: string): string => (isDim ? dim(hex) : hex)
157  const pixels = [...(tier.label === 'light' ? FLAGGED : BODY), LEGS[status === 'running' ? frame % 2 : 0] ?? '']
158  const colorOf = (char: string): string | undefined => {
159    if (char === 'H') return shade(tier.hat)
160    if (char === 'b') return shade(tier.band ?? tier.hat)
161    if (char === 'B' || char === 'L') return body
162    if (char === 'E') return '#1E1B2E'
163    if (char === 'A') return tier.label === 'heavy' ? shade('#60A5FA') : undefined
164    if (char === 'F') return shade('#E5E7EB')
165    return undefined
166  }
167  const rows: Cell[][] = []
168  for (let row = 0; row < pixels.length; row += 2) {
169    const top = pixels[row] ?? ''
170    const bottom = pixels[row + 1] ?? ''
171    const cells: Cell[] = []
172    for (let col = 0; col < 8; col += 1) {
173      const up = colorOf(top.charAt(col))
174      const down = colorOf(bottom.charAt(col))
175      const cell: Cell = up === undefined && down === undefined ? { char: ' ' } : up === undefined ? { char: '▄', fg: down } : { char: '▀', fg: up, bg: down }
176      const last = cells[cells.length - 1]
177      if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.char += cell.char
178      else cells.push(cell)
179    }
180    rows.push(cells)
181  }
182  return rows
183}
184
185function dim(hex: string): string {
186  const value = Number.parseInt(hex.slice(1), 16)
187  const channel = (shift: number): number => Math.round(((value >> shift) & 255) * 0.55)
188  return `#${[16, 8, 0].map(shift => channel(shift).toString(16).padStart(2, '0')).join('')}`
189}
190
types/index.d.ts 52 lines
1export type AgentStatus = 'running' | 'completed' | 'failed' | 'stopped'
2
3export type CrewAgent = {
4  /** The subagent's id, as `$.agent.list()` and its loop's events name it. */
5  id: string
6  /** Spawn order, from 1. */
7  n: number
8  title: string
9  type: string
10  /** The task it was given, cut short. */
11  prompt: string
12  model?: string
13  effort?: string
14  status: AgentStatus
15  startedAt: number
16  endedAt?: number
17  /** Every token its requests used: input, cached input and output. */
18  tokens: number
19  /** Input tokens of its latest request: how full its context is. */
20  context: number
21  /** Its share of the session cost, from the cost added by each of its requests. */
22  costUsd: number
23  tools: number
24  /** The tool it is running now, with what it is about. */
25  current?: string
26  /** The first line of its final answer, or why it stopped. */
27  answer?: string
28  parentId?: string
29}
30
31export type PlanStatus = 'pending' | 'in_progress' | 'completed'
32
33/** A task from Claude's task list (TaskCreate) or todo list (TodoWrite). */
34export type PlanTask = { id: string; subject: string; status: PlanStatus; blockedBy: string[]; owner?: string }
35
36declare module 'claude-code' {
37  interface PluginState {
38    crew: {
39      agents: CrewAgent[]
40      tasks: PlanTask[]
41      sessionTitle: string
42      startedAt: number
43      totalTokens: number
44      costUsd: number | null
45      contextWindow: number
46      isCompletedOpen: boolean
47      isPlannedOpen: boolean
48      expanded: string | null
49    }
50  }
51}
52