SLOPSHOPPER

mission-control

Live view of what Claude is executing: every tool call with its input, output and status, subagents in their own lanes, and a network-tab style timeline of…

newpaneguardcommandtimer
v0.1.0no licenseupdated 2026-10-03harshitmywork17/claude-mods/plugins/mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ done turn 1/1 · 360ms │ ┃ “(before this view opened)” ⏺ Read(src/auth.ts) │ ┃ 9 tool calls · 1 failed · 0 subagents · ⎿ Read 6 lines │ ┃ model 180ms · 2100 in / 1480 out tokens ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▌Main ⏺ Bash(bun test) │ ┃ ✓ Read auth.ts █ ⎿ 3 pass, 1 fail │ ┃ ✓ Grep refresh\( █ │ ┃ ✓ Edit auth.ts █ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Write audit.ts █ │ ┃ ✓ Write cache.ts █ ✻ Worked for 42s · done 4:20 PM │ ┃ ✗ Bash bun test █ │ ┃ ✓ Bash git status -… █ › /mission │ ┃ ✓ Bash rm -rf build… █ │ ┃ ✓ Bash cat .env │ ┃ │ ┃ [ show outputs ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission Control
✓ done turn 1/1 · 360ms “(before this view opened)” 9 tool calls · 1 failed · 0 subagents · model 180ms · 2100 in / 1480 out tokens ▌Main ✓ Read auth.ts █ 20ms ✓ Grep refresh\( █ 20ms ✓ Edit auth.ts █ 20ms ✓ Write audit.ts █ 20ms ✓ Write cache.ts █ 20ms ✗ Bash bun test █ 20ms ✓ Bash git status -… █ 20ms ✓ Bash rm -rf build… █ 20ms ✓ Bash cat .env █ 20ms [ show outputs ]
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 261 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Step, Turn } from '../types'
5import {
6  BADGE,
7  MAX_TURNS,
8  bar,
9  colorOf,
10  modelTime,
11  onNewest,
12  preview,
13  relabel,
14  seconds,
15  statusOf,
16  summarize,
17  withStep,
18  withStepChange,
19} from './trace'
20
21const PANE = 'mission-control'
22const TICK_MS = 1000
23const turns = atom({ plugin: 'mission-control', key: 'turns' } as const, [])
24const back = atom({ plugin: 'mission-control', key: 'back' } as const, 0)
25const view = atom({ plugin: 'mission-control', key: 'view' } as const, 'timeline')
26
27const TURN_STATUS = { running: '● running', done: '✓ done', interrupted: '⊘ interrupted', failed: '✗ failed' } as const
28
29async function laneLabel($: EngineInterface, laneId: string): Promise<string> {
30  if (laneId === 'main') return 'Main'
31  try {
32    const agent = (await $.agent.list()).find(one => one.id === laneId)
33    if (agent !== undefined) return `${agent.name ?? agent.type}: ${agent.description}`
34  } catch {
35    // The list is a nicety; a lane without it is labelled by id.
36  }
37  return `Subagent ${laneId.slice(0, 8)}`
38}
39
40function agentRunOf(input: Record<string, unknown>, result: unknown): AgentRun {
41  const record = (result ?? {}) as Record<string, unknown>
42  const num = (key: string): number | undefined => (typeof record[key] === 'number' ? (record[key] as number) : undefined)
43  return {
44    agentId: typeof record.agentId === 'string' ? record.agentId : undefined,
45    type: typeof input.subagent_type === 'string' ? input.subagent_type : 'general-purpose',
46    description: typeof input.description === 'string' ? input.description : '',
47    toolCount: num('totalToolUseCount'),
48    tokens: num('totalTokens'),
49  }
50}
51
52export const register: Register = on => {
53  let isBusy = false
54
55  on('session.start', async ($, e, next) => {
56    await $.command.register({
57      name: 'mission',
58      description: 'Mission Control: live view of tool calls, outputs, subagents and timing',
59      immediate: true,
60    })
61    $.clock.every(TICK_MS, () => {
62      if (isBusy) $.ui.invalidate('ui.render')
63    })
64
65    return next(e)
66  })
67
68  on('turn.start', async ($, e, next) => {
69    const now = await $.clock.now()
70    const turn: Turn = {
71      id: e.turnId,
72      prompt: e.text.replace(/\s+/g, ' ').slice(0, 100),
73      startedAt: now,
74      status: 'running',
75      steps: [],
76      lanes: [{ id: 'main', label: 'Main' }],
77    }
78    await update($, turns, list => [...list, turn].slice(-MAX_TURNS))
79    await update($, back, () => 0)
80    isBusy = true
81
82    return next(e)
83  })
84
85  on('tool.call', async ($, e, next) => {
86    const input = e as unknown as Record<string, unknown>
87    const lane = e.agentId ?? 'main'
88    const now = await $.clock.now()
89    const step: Step = {
90      id: e.tool_use_id,
91      lane,
92      tool: String(e.tool),
93      summary: summarize(String(e.tool), input),
94      startedAt: now,
95      status: 'running',
96      output: '',
97    }
98    const known = (await read($, turns)).at(-1)?.lanes.some(one => one.id === lane) ?? false
99    const label = known ? '' : await laneLabel($, lane)
100    await update($, turns, list => onNewest(list, now, turn => withStep(turn, step, label)))
101    isBusy = true
102
103    const ran = await next(e)
104
105    const status = statusOf(ran)
106    const change: Partial<Step> = {
107      endedAt: await $.clock.now(),
108      status,
109      output: preview(status === 'denied' ? ran.deny : ran.text),
110    }
111    if (e.tool === 'Agent' && status === 'ok') {
112      const agent = agentRunOf(input, ran.result)
113      change.agent = agent
114      if (agent.agentId !== undefined) {
115        const label = `${agent.type}${agent.description === '' ? '' : `: ${agent.description}`}`
116        await update($, turns, list => relabel(list, agent.agentId ?? '', label))
117      }
118    }
119    await update($, turns, list => withStepChange(list, step.id, change))
120
121    return ran
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    const ran = await next(e)
126    if (e.agentId !== undefined) return ran
127
128    const endedAt = await $.clock.now()
129    const status = e.reason === 'aborted' ? 'interrupted' : e.reason === 'answer' ? 'done' : 'failed'
130    await update($, turns, list =>
131      onNewest(list, endedAt, turn => ({
132        ...turn,
133        endedAt,
134        status,
135        inputTokens: e.usage?.input_tokens,
136        outputTokens: e.usage?.output_tokens,
137      })),
138    )
139    isBusy = false
140
141    return ran
142  })
143
144  on('session.end', async ($, e, next) => {
145    if (e.reason === 'clear') await update($, turns, () => [])
146
147    return next(e)
148  })
149
150  on('command.run', { command: 'mission' }, async $ => {
151    await $.ui.open({ id: PANE, title: 'Mission Control' })
152
153    return {}
154  })
155
156  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
157    const { Box, Button, Text } = $.ui.resolve(e)
158    const list = await read($, turns)
159    const offset = Math.min(await read($, back), Math.max(0, list.length - 1))
160    const turn = list[list.length - 1 - offset]
161    const mode = await read($, view)
162    const now = await $.clock.now()
163
164    if (turn === undefined) {
165      return <Text dimColor>Waiting for the next turn. Tool calls, subagents and timings appear here live.</Text>
166    }
167
168    const total = (turn.endedAt ?? now) - turn.startedAt
169    const columns = e.props.bodyColumns
170    const barWidth = Math.max(10, Math.floor(columns * 0.35))
171    const summaryWidth = Math.max(8, columns - barWidth - 24)
172    const tools = turn.steps.length
173    const failed = turn.steps.filter(step => step.status === 'error' || step.status === 'denied').length
174
175    return (
176      <Box flexDirection="column">
177        <Box flexDirection="row" gap={1}>
178          <Text bold color={turn.status === 'running' ? 'cyan' : turn.status === 'done' ? 'green' : 'yellow'}>
179            {TURN_STATUS[turn.status]}
180          </Text>
181          <Text dimColor>
182            turn {list.length - offset}/{list.length} · {seconds(total)}
183          </Text>
184        </Box>
185        <Text wrap="truncate-end">“{turn.prompt}”</Text>
186        <Text dimColor>
187          {tools} tool call{tools === 1 ? '' : 's'}
188          {failed > 0 ? ` · ${failed} failed` : ''} · {turn.lanes.length - 1} subagent
189          {turn.lanes.length === 2 ? '' : 's'} · model {seconds(modelTime(turn, now))}
190          {turn.outputTokens === undefined ? '' : ` · ${turn.inputTokens ?? 0} in / ${turn.outputTokens} out tokens`}
191        </Text>
192        {turn.lanes.map(lane => {
193          const steps = turn.steps.filter(step => step.lane === lane.id)
194          if (steps.length === 0 && lane.id !== 'main') return null
195          return (
196            <Box key={`lane-${lane.id}`} flexDirection="column" marginTop={1}>
197              <Text bold color={lane.id === 'main' ? 'white' : 'magenta'} wrap="truncate-end">
198                {lane.id === 'main' ? '▌Main' : `▌⑂ ${lane.label}`}
199              </Text>
200              {steps.length === 0 && <Text dimColor>  no tool calls yet</Text>}
201              {steps.map(step => {
202                const color = colorOf(step, now)
203                const { pad, fill } = bar(step, turn, now, barWidth)
204                return (
205                  <Box key={`step-${step.id}`} flexDirection="column">
206                    <Box flexDirection="row">
207                      <Box width={3} flexShrink={0}>
208                        <Text color={color}> {BADGE[step.status]}</Text>
209                      </Box>
210                      <Box width={12} flexShrink={0}>
211                        <Text bold wrap="truncate-end">{step.tool}</Text>
212                      </Box>
213                      <Box width={summaryWidth} flexShrink={1}>
214                        <Text dimColor wrap="truncate-end">{step.summary}</Text>
215                      </Box>
216                      {mode === 'timeline' && (
217                        <Box width={barWidth} flexShrink={0}>
218                          <Text>{pad}</Text>
219                          <Text color={color}>{fill}</Text>
220                        </Box>
221                      )}
222                      <Box width={8} flexShrink={0} justifyContent="flex-end">
223                        <Text color={color}>{seconds((step.endedAt ?? now) - step.startedAt)}</Text>
224                      </Box>
225                    </Box>
226                    {step.agent !== undefined && (
227                      <Text color="magenta">
228                        {'    '}⑂ subagent {step.agent.type}
229                        {step.agent.toolCount === undefined ? '' : ` · ${step.agent.toolCount} tools`}
230                        {step.agent.tokens === undefined ? '' : ` · ${step.agent.tokens} tokens`}
231                      </Text>
232                    )}
233                    {mode === 'details' && step.output !== '' && (
234                      <Text dimColor wrap="truncate-end">
235                        {'    ↳ '}
236                        {step.output.replace(/\n/g, ' ⏎ ')}
237                      </Text>
238                    )}
239                  </Box>
240                )
241              })}
242            </Box>
243          )
244        })}
245        <Box flexDirection="row" gap={1} marginTop={1}>
246          <Button
247            key="view"
248            label={mode === 'timeline' ? 'show outputs' : 'show timeline'}
249            hotkey="v"
250            onPress={() => update($, view, current => (current === 'timeline' ? 'details' : 'timeline'))}
251          />
252          {offset < list.length - 1 && (
253            <Button key="older" label="older turn" hotkey="o" onPress={() => update($, back, n => n + 1)} />
254          )}
255          {offset > 0 && <Button key="newer" label="newer turn" hotkey="w" onPress={() => update($, back, n => n - 1)} />}
256        </Box>
257      </Box>
258    )
259  })
260}
261
hooks/trace.ts 131 lines
1import type { Lane, Step, StepStatus, Turn } from '../types'
2
3export const MAX_TURNS = 5
4const MAX_STEPS = 200
5const MAX_OUTPUT = 240
6
7function basename(path: string): string {
8  return path.split('/').filter(Boolean).pop() ?? path
9}
10
11function firstString(input: Record<string, unknown>): string {
12  const found = Object.values(input).find(value => typeof value === 'string')
13  return typeof found === 'string' ? found : ''
14}
15
16/** One short line saying what a tool call is about. */
17export function summarize(tool: string, input: Record<string, unknown>): string {
18  const get = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
19  switch (tool) {
20    case 'Bash':
21      return get('command')
22    case 'Read':
23    case 'Edit':
24    case 'Write':
25      return basename(get('file_path'))
26    case 'NotebookEdit':
27      return basename(get('notebook_path'))
28    case 'Grep':
29    case 'Glob':
30      return get('pattern')
31    case 'WebFetch':
32      return get('url')
33    case 'WebSearch':
34      return get('query')
35    case 'Agent':
36      return [get('subagent_type'), get('description')].filter(Boolean).join(': ')
37    case 'Skill':
38      return get('skill')
39    default:
40      return firstString(input)
41  }
42}
43
44export function preview(text: string | undefined): string {
45  const lines = (text ?? '').trim().split('\n').slice(0, 3).join('\n')
46  return lines.length > MAX_OUTPUT ? `${lines.slice(0, MAX_OUTPUT)}…` : lines
47}
48
49export function statusOf(ran: { deny?: string; isError?: true }): StepStatus {
50  if (ran.deny !== undefined) return 'denied'
51  return ran.isError === true ? 'error' : 'ok'
52}
53
54/** Applies a change to the newest turn, creating one when a tool runs before any turn started. */
55export function onNewest(turns: readonly Turn[], now: number, change: (turn: Turn) => Turn): Turn[] {
56  const newest = turns[turns.length - 1] ?? {
57    id: 'earlier',
58    prompt: '(before this view opened)',
59    startedAt: now,
60    status: 'running',
61    steps: [],
62    lanes: [{ id: 'main', label: 'Main' }],
63  }
64  return [...turns.slice(0, -1), change(newest)]
65}
66
67export function withStep(turn: Turn, step: Step, laneLabel: string): Turn {
68  const hasLane = turn.lanes.some(lane => lane.id === step.lane)
69  const lanes: Lane[] = hasLane ? turn.lanes : [...turn.lanes, { id: step.lane, label: laneLabel }]
70  return { ...turn, lanes, steps: [...turn.steps, step].slice(-MAX_STEPS) }
71}
72
73export function withStepChange(turns: readonly Turn[], id: string, change: Partial<Step>): Turn[] {
74  return turns.map(turn =>
75    turn.steps.some(step => step.id === id)
76      ? { ...turn, steps: turn.steps.map(step => (step.id === id ? { ...step, ...change } : step)) }
77      : turn,
78  )
79}
80
81export function relabel(turns: readonly Turn[], laneId: string, label: string): Turn[] {
82  return turns.map(turn => ({
83    ...turn,
84    lanes: turn.lanes.some(lane => lane.id === laneId)
85      ? turn.lanes.map(lane => (lane.id === laneId ? { ...lane, label } : lane))
86      : turn.lanes,
87  }))
88}
89
90export function seconds(ms: number): string {
91  if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`
92  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
93  const minutes = Math.floor(ms / 60_000)
94  return `${minutes}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`
95}
96
97/** Time the main lane spent outside tool calls: the model reading, deciding and writing. */
98export function modelTime(turn: Turn, now: number): number {
99  const end = turn.endedAt ?? now
100  const spans = turn.steps
101    .filter(step => step.lane === 'main')
102    .map(step => [step.startedAt, step.endedAt ?? now] as const)
103    .sort((a, b) => a[0] - b[0])
104  let busy = 0
105  let reach = turn.startedAt
106  for (const [start, stop] of spans) {
107    const from = Math.max(start, reach)
108    if (stop > from) busy += stop - from
109    reach = Math.max(reach, stop)
110  }
111  return Math.max(0, end - turn.startedAt - busy)
112}
113
114/** A waterfall bar: leading spaces then blocks, placed on the turn's time span. */
115export function bar(step: Step, turn: Turn, now: number, width: number): { pad: string; fill: string } {
116  const span = Math.max(1, (turn.endedAt ?? now) - turn.startedAt)
117  const start = Math.min(width - 1, Math.floor(((step.startedAt - turn.startedAt) / span) * width))
118  const length = Math.max(1, Math.round((((step.endedAt ?? now) - step.startedAt) / span) * width))
119  return { pad: ' '.repeat(Math.max(0, start)), fill: '█'.repeat(Math.min(length, width - start)) }
120}
121
122export function colorOf(step: Step, now: number): string {
123  if (step.status === 'running') return 'cyan'
124  if (step.status === 'error') return 'red'
125  if (step.status === 'denied') return 'magenta'
126  const took = (step.endedAt ?? now) - step.startedAt
127  return took >= 30_000 ? 'red' : took >= 10_000 ? 'yellow' : 'green'
128}
129
130export const BADGE: Record<StepStatus, string> = { running: '●', ok: '✓', error: '✗', denied: '⊘' }
131
types/index.d.ts 54 lines
1export type StepStatus = 'running' | 'ok' | 'error' | 'denied'
2
3export type AgentRun = {
4  agentId?: string
5  type: string
6  description: string
7  toolCount?: number
8  tokens?: number
9}
10
11export type Step = {
12  id: string
13  /** 'main', or the subagent's id. */
14  lane: string
15  tool: string
16  summary: string
17  startedAt: number
18  endedAt?: number
19  status: StepStatus
20  /** First lines of what the tool returned, or why it failed. */
21  output: string
22  agent?: AgentRun
23}
24
25export type Lane = { id: string; label: string }
26
27export type TurnStatus = 'running' | 'done' | 'interrupted' | 'failed'
28
29export type Turn = {
30  id: string
31  prompt: string
32  startedAt: number
33  endedAt?: number
34  status: TurnStatus
35  steps: Step[]
36  lanes: Lane[]
37  inputTokens?: number
38  outputTokens?: number
39}
40
41export type View = 'timeline' | 'details'
42
43declare module 'claude-code' {
44  interface PluginState {
45    'mission-control': {
46      /** Oldest first, newest last. */
47      turns: Turn[]
48      /** How many turns back from the newest the pane shows; 0 is the newest. */
49      back: number
50      view: View
51    }
52  }
53}
54