SLOPSHOPPER

agent-watch

Shows who is doing the work this turn: the main chat, every helper agent, its model, task, status and the skills that ran, in a band above the prompt and a…

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-watch
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents │ ┃ ⏺ Read(src/auth.ts) │ ┃ main ✓ ⎿ Read 6 lines │ ┃ model unknown · calls 9 · tokens 0 in / 0 ⏺ Update(src/auth.ts) │ ┃ out ⎿ Added 2 lines, removed 1 line │ ┃ skills: none ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Totals per model │ ┃ unknown 0 steps · 9 calls · 0 in / 0 out ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Timeline (0s) ✻ Worked for 42s · done 4:20 PM │ ┃ ██████████████████████████ main │ › /agent-watch │ ⎿ agent-watch: Agents pane opened. │ │ AGENTS ◎ main ✓ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
AGENTS ◎ main ✓ ⟨Claude Code's own drawing⟩
Pane · Agents
Agents main ✓ model unknown · calls 9 · tokens 0 in / 0 out skills: none Totals per model unknown 0 steps · 9 calls · 0 in / 0 out Timeline (0s) ██████████████████████████ main
README

lchase

Lawrence Chase's Claude Code marketplace: code review, diagram, and test-evidence skills, plus a set of small Claude Code mods that add band lines and panes to the terminal UI.

/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
/plugin install test-drive@lchase
/plugin install cache-clock@lchase
/plugin install usage-gauge@lchase
/plugin install auto-handoff@lchase
/plugin install agent-watch@lchase
/plugin install recording-mode@lchase

Each plugin is independent; install only what you want. @lchase is the marketplace name (from .claude-plugin/marketplace.json), not the GitHub slug.

Updating

New versions ship whenever the version string in a plugin's manifest changes. Claude Code refreshes marketplaces in the background by default, so you normally pick up updates without doing anything. To pull them right now:

/plugin marketplace update lchase      # refresh this marketplace's manifest
/plugin update smart-review@lchase     # apply a pending update to one plugin
/plugin update tldraw@lchase

/plugin marketplace update (no name) refreshes every marketplace. From a shell: claude plugin marketplace update lchase and claude plugin update <plugin>@lchase.

Reinstalling

If a plugin gets into a bad state (stale cached script, half-applied update), do a clean reinstall:

/plugin uninstall tldraw
/plugin marketplace update lchase
/plugin install tldraw@lchase

Nuclear option, re-register the whole marketplace:

/plugin marketplace remove lchase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase

Migrating from the old layout

Before this repo became a multi-plugin marketplace it published one plugin from the repo root under a marketplace named chase. If you installed it back then, clear the old registration first:

/plugin uninstall smart-review
/plugin uninstall tldraw            # only if you installed the short-lived tldraw@chase
/plugin marketplace remove chase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase

The tldraw skill's command also changed: /tldraw:tldraw → /tldraw:diagram.

PluginWhat it doesSkills / commandsHarnesses
smart-reviewEnsemble code review: six specialized lenses over a diff, merged into one deduplicated, severity-ranked verdict/smart-review:{review,min,max,add-pr-review,review-pr-comments} + auto-triggerClaude Code, Cursor, Codex, Gemini CLI, any AGENTS.md agent
tldrawNatural-language description → editable tldraw document (.tldr) + rendered PNG/SVG/tldraw:diagram + auto-triggerClaude Code only (needs Node + npx)
test-driveBefore/after evidence of a UI or API change: record the app before you edit, replay the same scripted scenario after, with a deterministic verdict/test-drive:{baseline,compare} + auto-triggerClaude Code only (needs Node; UI also needs Playwright and ffmpeg)
cache-clockMod: band line showing whether the prompt cache is warm, with countdown, reuse %, cached tokens, and estimated cold-rewrite costCompact button, /cache-clockClaude Code only
usage-gaugeMod: band line with plan usage windows (5-hour, weekly, spend limit), reset times, pace warning, session cost on API billing, threshold toasts/usage-gauge (detail pane)Claude Code only
auto-handoffMod: band line showing how full the chat is; saves a HANDOFF.md at a threshold (auto or on request), then clear and continue or resume in a new session/auto-handoffClaude Code only
agent-watchMod: band line showing who is doing the work this turn (main chat, helper agents, models, tasks, status, skills), plus a pane with per-agent blocks, per-model totals, timeline/agent-watchClaude Code only
recording-modeMod: screen-record without leaking secrets; tells the model to keep keys, emails, private words off screen and masks them in the transcript display only, red REC chip/recClaude Code only

smart-review

An ensemble code review: several specialized lenses, one merged verdict, not another pile of comments.

Most code review, human or AI, is one reviewer making a single pass and leaving a list of comments. This runs six specialized lenses instead (spec-conformance, correctness, security, performance, design, and tests), then merges the results into one verdict: duplicates collapsed, findings that several lenses independently raise weighted up, conflicting advice reconciled, severity rolled up, structural problems first, nits capped. The merge is the point. An ensemble without it is just louder, and a longer checklist handed to one reviewer only dilutes its attention and repeats its own misses.

Two modes: a fast single pass (min) for tight loops, and an orchestrated ensemble (max) for pre-merge review. Auto-routing picks between them by change size and sensitivity.

The skill lives in plugins/smart-review/skills/smart-review/ as harness-neutral markdown. Each harness gets a thin manifest pointing at that same directory, so no skill content is copied or forked.

HarnessInstallminmaxSlash commands / auto-trigger
Claude Code/plugin marketplace add lchase/skills then /plugin install smart-review@lchase✅✅ isolated parallel subagents✅
Cursorpoint Cursor at plugins/smart-review/.cursor-plugin/✅✅ sequential ensemblevia session-start hook
Codexplugins/smart-review/.codex-plugin/ manifest✅✅ sequential ensembleno
Gemini CLIgemini extensions install https://github.com/lchase/skills (reads root gemini-extension.json)✅✅ sequential ensemblevia GEMINI.md
Any AGENTS.md agentreads root AGENTS.md✅✅ sequential ensembleno

max's isolated parallel subagents are a Claude Code capability. A model panel (plugins/smart-review/skills/smart-review/references/ensemble.md, off by default) rides on top when the harness can route more than one model. Otherwise max is single-model. Elsewhere max runs the same spec-gate, six lenses, and merge as a disciplined sequential walk. Design and internals: plugins/smart-review/SMART-REVIEW.md.

Claude Code slash commands:

  • /smart-review:review: auto (routes to min or max by size + sensitivity)
  • /smart-review:min: fast single-pass review
  • /smart-review:max: orchestrated ensemble review
  • /smart-review:add-pr-review <PR number or URL>: reviews a GitHub PR and, after you confirm what to publish, posts the findings as PR comments
  • /smart-review:review-pr-comments <PR number or URL>: triages a PR's existing unresolved review comments, fixes or explains each after your approval, replies, and resolves the thread

The skill also triggers automatically when you ask Claude to review a diff, PR, or branch.


tldraw

Turn a described diagram (flowchart, architecture / system diagram, process map, decision tree, state machine) into two artifacts: an editable .tldr document (opens at tldraw.com or in the tldraw editor) and a rendered PNG/SVG for docs and PRs.

Claude writes a {nodes, edges} spec from your description; a bundled Node script does a simple layered auto-layout, emits the .tldr, and renders images by loading it into a headless-Chromium tldraw instance via @kitschpatrol/tldraw-cli (first run downloads Chromium, ~1 min, cached).

/plugin install tldraw@lchase

Claude Code only. Skill: /tldraw:diagram (also auto-triggers on "draw this diagram", "diagram this flow", "make a tldraw of…"). Details: plugins/tldraw/skills/diagram/SKILL.md.


test-drive

A test drive is an e2e test that happens to produce video, and the "before" is part of the process rather than something reconstructed later. Run /test-drive:baseline before you edit: it writes a short browser scenario and records the app as it stands. Make the change, then run /test-drive:compare: it replays the identical scenario with Playwright and composes a labeled side-by-side MP4. The pass/warn/fail verdict comes from the scenario's assertions, not from reading the video.

Backend changes work the same way with "kind": "api": the scenario is a list of HTTP steps (method, path, body, expected status, JSON fields, and server-log lines to expect or avoid). There is nothing to screen-record, so each step captures the request, the response, and the server log lines it produced, and compare writes a before/after report.md instead of a video. Secrets are redacted from the stored transcript (best effort, extendable per scenario with redact), and the finished report is scanned and flagged if anything still looks secret.

/plugin install test-drive@lchase

Claude Code only. UI scenarios need ffmpeg and Playwright in the repo under test; API scenarios need only Node 18+. Only the working tree is ever run, so there is no second checkout to boot. Details: baseline and compare.


Claude Code mods

Five small plugins that change Claude Code's own interface instead of adding a task skill. Each draws a band line above the prompt (some add a side pane or slash command), is independent of the others, and works on Claude Code only.

  • cache-clock: is the prompt cache still warm? Countdown, reuse percentage, cached tokens, and the estimated cost of a cold rewrite, with a Compact button. Config: cacheMinutes (5 or 60, default 60) to match your cache TTL.
  • usage-gauge: plan usage windows (5-hour, weekly, spend limit) with reset times and a pace warning; session cost on API billing; threshold toasts. /usage-gauge opens a detail pane.
  • auto-handoff: watches context fill and saves a HANDOFF.md at a threshold (automatically or on request), then clears and continues, or resumes in a new session. /auto-handoff.
  • agent-watch: who is doing the work this turn: main chat, helper agents, models, tasks, status, skills. /agent-watch opens a pane with per-agent blocks, per-model totals, and a timeline.
  • recording-mode: for screen recordings. Tells the model to keep keys, emails, and private words off screen and masks them in the transcript display only (the underlying transcript is untouched). Red REC chip, /rec to toggle.
/plugin install cache-clock@lchase    # or usage-gauge, auto-handoff, agent-watch, recording-mode

Repo layout

.claude-plugin/marketplace.json   # the "lchase" marketplace: 8 plugin entries
AGENTS.md                         # repo-level agent pointer (→ smart-review)
gemini-extension.json + GEMINI.md # Gemini CLI installs the repo as an extension (→ smart-review)

plugins/smart-review/             # plugin 1, multi-harness code review
  .claude-plugin/plugin.json
  skills/smart-review/            # the harness-neutral core (SKILL.md + references/)
  agents/ commands/ hooks/        # Claude Code packaging
  .cursor-plugin/ .codex-plugin/  # thin per-harness manifests
  scripts/                        # validate-adapters.sh, bump-version.sh
  evals/                          # dev-only eval kit (not shipped to installers)
  SMART-REVIEW.md                 # design writeup

plugins/tldraw/                   # plugin 2, Claude Code only
  .claude-plugin/plugin.json
  skills/diagram/                 # SKILL.md + scripts/build-tldr.mjs + references/

plugins/test-drive/               # plugin 3, Claude Code only
  .claude-plugin/plugin.json
  skills/{baseline,compare}/      # SKILL.md each
  scripts/{lib.mjs,record.mjs,record-api.mjs,redact.mjs,verdict.mjs,compose.sh,report.mjs}   # shared by both skills
  references/                     # UI and API scenario schemas + examples

plugins/{cache-clock,usage-gauge,auto-handoff,agent-watch,recording-mode}/   # plugins 4-8, Claude Code mods
  .claude-plugin/plugin.json      # each with its own version
  hooks/                          # register.tsx (the mod) + hooks.json, tests where present
  types/                          # mod API typings

Developing

claude plugin validate .                                # marketplace + all plugins
./plugins/smart-review/scripts/validate-adapters.sh      # smart-review's per-harness manifests

Releasing: users only get an update when a plugin's version string changes, so bump it on every user-facing change and push to main.

  • smart-review: version is pinned across all its manifests; bump them together with ./plugins/smart-review/scripts/bump-version.sh <version>.
  • tldraw, test-drive, and each mod: version independently; edit that plugin's .claude-plugin/plugin.json.

Once pushed, installed clients pick it up on their next background marketplace refresh, or immediately with /plugin marketplace update lchase.

Source 3 files
hooks/register.tsx 255 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run, Settings } from '../types'
5import {
6  HELP,
7  MAIN,
8  addCall,
9  addSkill,
10  addUsage,
11  bandSegs,
12  emptyRun,
13  modelColor,
14  seconds,
15  statusColor,
16  statusMark,
17  timelineBar,
18  tokens,
19  touch,
20  tree,
21} from './lib'
22
23const PANE = 'agent-watch'
24const DEFAULTS: Settings = { isOn: true }
25
26const run = atom({ plugin: 'agent-watch', key: 'run' } as const, null)
27const settings = atom({ plugin: 'agent-watch', key: 'settings' } as const, DEFAULTS)
28
29const isSettings = (v: unknown): v is Settings =>
30  typeof v === 'object' && v !== null && typeof (v as Settings).isOn === 'boolean'
31
32const agentOf = (e: { agentId?: string }): string => e.agentId ?? MAIN
33
34/** Applies `fn` to the current turn's run, starting one when none is open. */
35async function change($: EngineInterface, fn: (r: Run, now: number) => Run) {
36  try {
37    const now: number = await $.clock.now()
38    await update($, run, cur => fn(cur ?? touch(emptyRun(now), MAIN, now, () => ({})), now))
39  } catch {
40    // Watching must never break a tool call or a turn.
41  }
42}
43
44
45export const register: Register = on => {
46  on('session.start', async ($, e, next) => {
47    const saved = await $.store.get('settings')
48    if (isSettings(saved)) await update($, settings, () => saved)
49    await $.command.register({
50      name: 'agent-watch',
51      description: 'Who is doing the work this turn: agents, models, tasks, skills',
52      argumentHint: 'on | off | clear | help',
53    })
54
55    return next(e)
56  })
57
58  on('turn.start', async ($, e, next) => {
59    if (!e.turnId) return next(e)
60    const now: number = await $.clock.now()
61    await update($, run, () => touch(emptyRun(now), MAIN, now, () => ({ status: 'running' })))
62
63    return next(e)
64  })
65
66  on('agent.spawn', async ($, e, next) => {
67    const started = await next(e)
68    if ('agentId' in started && started.agentId) {
69      const id = started.agentId
70      await change($, (r, now) =>
71        touch(r, id, now, a => ({
72          type: e.name ?? e.subagentType,
73          task: e.description || e.prompt,
74          model: a.model || started.model,
75          parent: e.parentAgentId ?? MAIN,
76        })),
77      )
78    }
79
80    return started
81  })
82
83  on('tool.call', async ($, e, next) => {
84    const id = agentOf(e)
85    await change($, (r, now) => {
86      const counted = addCall(r, id, now)
87      // Remember who called the Skill tool, so its skill.prompt lands on that agent.
88      return e.tool === 'Skill'
89        ? { ...counted, pending: [...counted.pending, { skill: String(e.skill), agentId: id }] }
90        : counted
91    })
92
93    return next(e)
94  })
95
96  on('skill.prompt', async ($, e, next) => {
97    await change($, (r, now) => {
98      const at = r.pending.findIndex(p => p.skill === e.skill)
99      const owner = at >= 0 ? (r.pending[at]?.agentId ?? MAIN) : MAIN
100      const rest = at >= 0 ? r.pending.filter((_, i) => i !== at) : r.pending
101
102      return addSkill({ ...r, pending: rest }, owner, e.skill, now)
103    })
104
105    return next(e)
106  })
107
108  on('turn.step', async function* ($, e, next) {
109    const result = yield* next(e)
110    const used = result?.usage
111    await change($, (r, now) =>
112      addUsage(
113        r,
114        agentOf(e),
115        used?.model ?? e.model,
116        used ? used.input_tokens + used.cache_read_input_tokens + used.cache_creation_input_tokens : 0,
117        used ? used.output_tokens : 0,
118        now,
119      ),
120    )
121
122    return result
123  })
124
125  on('turn.complete', async ($, e, next) => {
126    const id = agentOf(e)
127    await change($, (r, now) => {
128      const done = touch(r, id, now, () => ({
129        status: e.reason === 'answer' ? 'done' : 'failed',
130        endedAt: now,
131      }))
132
133      return id === MAIN ? { ...done, endedAt: now } : done
134    })
135
136    return next(e)
137  })
138
139  on('command.run', { command: 'agent-watch' }, async ($, e) => {
140    const word = (e.args.trim().split(/\s+/)[0] ?? '').toLowerCase()
141    const cfg: Settings = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
142
143    if (word === '') {
144      await $.ui.open({ id: PANE, title: 'Agents' })
145
146      return { text: 'Agents pane opened.' }
147    }
148    if (word === 'on' || word === 'off') {
149      const next: Settings = { ...cfg, isOn: word === 'on' }
150      await update($, settings, () => next)
151      await $.store.set('settings', next)
152
153      return { text: `agent-watch band ${word}.` }
154    }
155    if (word === 'clear') {
156      await update($, run, () => null)
157
158      return { text: 'agent-watch cleared.' }
159    }
160
161    return { text: HELP }
162  })
163
164  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
165    const cfg: Settings = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
166    const r = await read($, run)
167    if (e.props.hasSurvey || !cfg.isOn || r === null) return next(e)
168
169    const { Box, Text } = $.ui.resolve(e)
170    const line = (
171      <Text wrap="truncate-end">
172        {bandSegs(r, e.props.bodyColumns).map(s => (
173          <Text bold={s.bold} dimColor={s.dim} color={s.color} backgroundColor={s.bg}>
174            {s.t}
175          </Text>
176        ))}
177      </Text>
178    )
179    const below = await next(e)
180
181    return below ? (
182      <Box flexDirection="column">
183        {line}
184        {below}
185      </Box>
186    ) : (
187      line
188    )
189  })
190
191  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
192    const { Box, Text } = $.ui.resolve(e)
193    const r = await read($, run)
194    if (r === null) {
195      return (
196        <Box flexDirection="column">
197          <Text bold>Agents</Text>
198          <Text dimColor>No turn yet.</Text>
199        </Box>
200      )
201    }
202
203    const now: number = await $.clock.now()
204    const total = Math.max(1, (r.endedAt ?? now) - r.startedAt)
205    const rows = tree(r)
206    const barWidth = Math.max(10, Math.min(40, (e.props.bodyColumns ?? 60) - 30))
207    const models = Object.entries(r.byModel)
208
209    return (
210      <Box flexDirection="column">
211        <Text bold>Agents</Text>
212        {rows.map(({ row, depth }) => (
213          <Box flexDirection="column" marginTop={1} marginLeft={depth * 2}>
214            <Text>
215              <Text bold>{row.type}</Text>
216              {row.task ? <Text bold> — {row.task}</Text> : null}{' '}
217              <Text color={statusColor(row.status)}>{statusMark(row.status)}</Text>
218            </Text>
219            <Text dimColor>
220              model <Text color={modelColor(row.model)}>{row.model || 'unknown'}</Text>
221              {' · '}calls {row.calls}
222              {' · '}tokens {tokens(row.tokensIn)} in / {tokens(row.tokensOut)} out
223            </Text>
224            <Text dimColor>skills: {row.skills.length > 0 ? row.skills.join(', ') : 'none'}</Text>
225          </Box>
226        ))}
227        <Box flexDirection="column" marginTop={1}>
228          <Text bold>Totals per model</Text>
229          {models.length === 0 && <Text dimColor>No model steps yet.</Text>}
230          {models.map(([label, m]) => (
231            <Text>
232              <Text color={modelColor(label)}>{label}</Text>
233              <Text dimColor>
234                {' '}
235                {m.steps} steps · {m.calls} calls · {tokens(m.tokensIn)} in / {tokens(m.tokensOut)} out
236              </Text>
237            </Text>
238          ))}
239        </Box>
240        <Box flexDirection="column" marginTop={1}>
241          <Text bold>Timeline ({seconds(total)})</Text>
242          {rows.map(({ row }) => (
243            <Text>
244              <Text color={statusColor(row.status)}>
245                {timelineBar(row.startedAt - r.startedAt, (row.endedAt ?? now) - r.startedAt, total, barWidth)}
246              </Text>
247              <Text dimColor> {row.type}</Text>
248            </Text>
249          ))}
250        </Box>
251      </Box>
252    )
253  })
254}
255
hooks/lib.ts 214 lines
1import type { AgentRow, Run, Status } from '../types'
2
3export const CHIP_FG = '#b8aece'
4export const CHIP_BG = '#3b2160'
5export const SKILL_COLOR = '#a3e635'
6export const TASK_MAX = 22
7export const MAIN = 'main'
8
9// same purple as auto-handoff's branch name
10const MODEL_COLOR = '#b794f4'
11const STATUS_COLOR: Record<Status, string> = {
12  running: '#f59e0b',
13  done: '#22c55e',
14  failed: '#ef4444',
15}
16const STATUS_MARK: Record<Status, string> = { running: '⟳', done: '✓', failed: '✗' }
17
18export const statusColor = (s: Status): string => STATUS_COLOR[s]
19export const statusMark = (s: Status): string => STATUS_MARK[s]
20
21const FAMILY_RE = /(fable|opus|sonnet|haiku)/
22
23/** `claude-opus-5-5` -> `opus·5.5`, `claude-3-5-sonnet-20241022` -> `sonnet·3.5`, `haiku` -> `haiku`. */
24export function modelLabel(raw: string | undefined): string {
25  if (!raw) return ''
26  const id = raw.toLowerCase().replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
27  const m = FAMILY_RE.exec(id)
28  if (!m) return raw
29  const family = m[1] as string
30  const after = id.slice(m.index + family.length)
31  const before = id.slice(0, m.index)
32  const a = /^-(\d+)(?:-(\d+))?$/.exec(after)
33  if (a) return `${family}·${a[1]}${a[2] ? `.${a[2]}` : ''}`
34  const b = /(\d+)(?:-(\d+))?-$/.exec(before)
35  if (b) return `${family}·${b[1]}${b[2] ? `.${b[2]}` : ''}`
36  return family
37}
38
39export function modelColor(label: string): string | undefined {
40  const m = FAMILY_RE.exec(label)
41  return m ? MODEL_COLOR : undefined
42}
43
44/** Task text cut to `max` characters, ending in an ellipsis when it was longer. */
45export function cut(text: string, max = TASK_MAX): string {
46  const t = text.replace(/\s+/g, ' ').trim()
47  return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t
48}
49
50export type Seg = { t: string; color?: string; bg?: string; bold?: boolean; dim?: boolean }
51
52export const text = (segs: readonly Seg[]): string => segs.map(s => s.t).join('')
53
54export function emptyRun(now: number): Run {
55  return { startedAt: now, order: [], agents: {}, skills: [], pending: [], byModel: {} }
56}
57
58export function blankAgent(id: string, now: number): AgentRow {
59  return {
60    id,
61    type: id === MAIN ? MAIN : 'agent',
62    task: '',
63    model: '',
64    status: 'running',
65    parent: MAIN,
66    calls: 0,
67    tokensIn: 0,
68    tokensOut: 0,
69    skills: [],
70    startedAt: now,
71  }
72}
73
74/** Reducer helper: the run with agent `id` present (created when unseen) and patched. */
75export function touch(run: Run, id: string, now: number, patch: (a: AgentRow) => Partial<AgentRow>): Run {
76  const existing = run.agents[id]
77  const base = existing ?? blankAgent(id, now)
78  const order = existing || run.order.includes(id) ? run.order : [...run.order, id]
79  return { ...run, order, agents: { ...run.agents, [id]: { ...base, ...patch(base) } } }
80}
81
82export function addSkill(run: Run, agentId: string, skill: string, now: number): Run {
83  const named = touch(run, agentId, now, a => (a.skills.includes(skill) ? {} : { skills: [...a.skills, skill] }))
84  return named.skills.includes(skill) ? named : { ...named, skills: [...named.skills, skill] }
85}
86
87export function addUsage(run: Run, agentId: string, model: string, tokensIn: number, tokensOut: number, now: number): Run {
88  const label = modelLabel(model)
89  const next = touch(run, agentId, now, a => ({
90    model: label || a.model,
91    tokensIn: a.tokensIn + tokensIn,
92    tokensOut: a.tokensOut + tokensOut,
93  }))
94  const key = label || 'unknown'
95  const m = next.byModel[key] ?? { steps: 0, calls: 0, tokensIn: 0, tokensOut: 0 }
96  return {
97    ...next,
98    byModel: { ...next.byModel, [key]: { ...m, steps: m.steps + 1, tokensIn: m.tokensIn + tokensIn, tokensOut: m.tokensOut + tokensOut } },
99  }
100}
101
102export function addCall(run: Run, agentId: string, now: number): Run {
103  const next = touch(run, agentId, now, a => ({ calls: a.calls + 1 }))
104  const label = next.agents[agentId]?.model || 'unknown'
105  const m = next.byModel[label] ?? { steps: 0, calls: 0, tokensIn: 0, tokensOut: 0 }
106  return { ...next, byModel: { ...next.byModel, [label]: { ...m, calls: m.calls + 1 } } }
107}
108
109/** Helpers in spawn order, main excluded. */
110export const helpers = (run: Run): AgentRow[] =>
111  run.order.filter(id => id !== MAIN).map(id => run.agents[id] as AgentRow)
112
113function agentSegs(a: AgentRow, withTask: boolean): Seg[] {
114  const out: Seg[] = [{ t: a.type, bold: a.id === MAIN }]
115  if (a.model) out.push({ t: ' ' }, { t: a.model, color: modelColor(a.model) })
116  if (withTask && a.id !== MAIN && a.task) out.push({ t: ` "${cut(a.task)}"` })
117  out.push({ t: ' ' }, { t: statusMark(a.status), color: statusColor(a.status) })
118  return out
119}
120
121const SEP: Seg = { t: ' │ ', dim: true }
122const DOT: Seg = { t: ' · ', dim: true }
123
124/**
125 * The band line as segments, fitted to `columns`. Task text goes first, oldest
126 * helper first, except the first helper's. Then newest helpers collapse to
127 * "+N more", then skills shrink to the first one plus "+N".
128 */
129export function bandSegs(run: Run, columns: number): Seg[] {
130  const all = helpers(run)
131  const main = run.agents[MAIN] ?? { ...blankAgent(MAIN, run.startedAt) }
132  const showTask = all.map(() => true)
133  let hidden = 0
134  let skillsShown = run.skills.length
135
136  const build = (): Seg[] => {
137    const segs: Seg[] = [{ t: '  AGENTS ◎ ', bold: true, color: CHIP_FG, bg: CHIP_BG }, { t: ' ' }, ...agentSegs(main, false)]
138    const visible = all.slice(0, all.length - hidden)
139    if (visible.length > 0 || hidden > 0) {
140      segs.push(SEP)
141      visible.forEach((a, i) => {
142        if (i > 0) segs.push(DOT)
143        segs.push(...agentSegs(a, showTask[i] as boolean))
144      })
145      if (hidden > 0) segs.push(...(visible.length > 0 ? [DOT] : []), { t: `+${hidden} more`, dim: true })
146    }
147    if (run.skills.length > 0) {
148      segs.push(SEP, { t: 'skills: ', dim: true })
149      run.skills.slice(0, skillsShown).forEach((s, i) => {
150        if (i > 0) segs.push({ t: ', ', dim: true })
151        segs.push({ t: s, color: SKILL_COLOR })
152      })
153      const more = run.skills.length - skillsShown
154      if (more > 0) segs.push({ t: ` +${more}`, dim: true })
155    }
156    return segs
157  }
158  const fits = (): boolean => text(build()).length <= columns
159
160  for (let i = 1; i < all.length && !fits(); i++) showTask[i] = false
161  while (!fits() && all.length - hidden > 1) hidden++
162  while (!fits() && skillsShown > 1) skillsShown--
163
164  return build()
165}
166
167export function tokens(n: number): string {
168  if (n < 1000) return String(n)
169  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
170  return `${(n / 1_000_000).toFixed(1)}M`
171}
172
173export function seconds(ms: number): string {
174  const s = Math.max(0, Math.round(ms / 1000))
175  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
176}
177
178/** A bar of `width` cells placing [start, end] inside [0, total]. */
179export function timelineBar(start: number, end: number, total: number, width: number): string {
180  if (width < 1) return ''
181  const span = Math.max(1, total)
182  const from = Math.min(width - 1, Math.max(0, Math.floor((start / span) * width)))
183  const to = Math.min(width, Math.max(from + 1, Math.ceil((end / span) * width)))
184  return '·'.repeat(from) + '█'.repeat(to - from) + '·'.repeat(width - to)
185}
186
187/** Agents depth-first: each helper right after its parent, spawn order among siblings. */
188export function tree(run: Run): { row: AgentRow; depth: number }[] {
189  const out: { row: AgentRow; depth: number }[] = []
190  const seen = new Set<string>()
191  const walk = (id: string, depth: number) => {
192    const row = run.agents[id]
193    if (!row || seen.has(id)) return
194    seen.add(id)
195    out.push({ row, depth })
196    for (const k of run.order) {
197      const child = run.agents[k]
198      const p = child && child.parent !== k && run.agents[child.parent] ? child.parent : MAIN
199      if (k !== MAIN && p === id) walk(k, depth + 1)
200    }
201  }
202  walk(MAIN, 0)
203  return out
204}
205
206export const HELP = [
207  'agent-watch: who is doing the work this turn.',
208  '/agent-watch          open the Agents pane',
209  '/agent-watch on       show the band above the prompt',
210  '/agent-watch off      hide the band (the pane still works)',
211  '/agent-watch clear    forget this turn',
212  '/agent-watch help     this text',
213].join('\n')
214
types/index.d.ts 41 lines
1export type Status = 'running' | 'done' | 'failed'
2
3export type AgentRow = {
4  id: string
5  /** Agent type or given name; `main` for the chat itself. */
6  type: string
7  task: string
8  /** Model label already shortened, e.g. `opus·5.5`; empty until known. */
9  model: string
10  status: Status
11  /** `main` or the id of the agent that spawned this one. */
12  parent: string
13  calls: number
14  tokensIn: number
15  tokensOut: number
16  skills: string[]
17  startedAt: number
18  endedAt?: number
19}
20
21export type Run = {
22  startedAt: number
23  endedAt?: number
24  /** Spawn order; `main` first. */
25  order: string[]
26  agents: Record<string, AgentRow>
27  /** Every skill that ran this turn, once each. */
28  skills: string[]
29  /** Skill tool calls waiting for their skill.prompt, to attribute it. */
30  pending: { skill: string; agentId: string }[]
31  byModel: Record<string, { steps: number; calls: number; tokensIn: number; tokensOut: number }>
32}
33
34export type Settings = { isOn: boolean }
35
36declare module 'claude-code' {
37  interface PluginState {
38    'agent-watch': { run: Run | null; settings: Settings }
39  }
40}
41