SLOPSHOPPER

Agent Narrator

Watch the agent work: every step in plain English, with a live time-saved counter

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-narrator
│ ┃ Agent at work ✕ › fix the failing auth test and add an audit log call │ ┃ DONE step 9 0:00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ESTIMATED HUMAN TIME SAVED ⎿ Read 6 lines │ ┃ 0m ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ✓ Reading the auth code ⏺ Bash(bun test) │ ┃ ✓ Searching the project for "refresh\(" ⎿ 3 pass, 1 fail │ ┃ ✓ Saving changes to the auth code │ ┃ ✓ Creating the audit code ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Creating the cache code │ ┃ × Running the test suite · did not work, ✻ Worked for 42s · done 4:20 PM │ ┃ trying another way │ ┃ ✓ Reviewing what has changed so far › /narrate │ ┃ ✓ Publishing the changes ⎿ agent-narrator: Narrator open. │ ┃ ✓ Looking around the project │ ┃ │ ┃ Claude · working for you │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent at work
DONE step 9 0:00 ESTIMATED HUMAN TIME SAVED 0m ✓ Reading the auth code ✓ Searching the project for "refresh\(" ✓ Saving changes to the auth code ✓ Creating the audit code ✓ Creating the cache code × Running the test suite · did not work, trying another way ✓ Reviewing what has changed so far ✓ Publishing the changes ✓ Looking around the project Claude · working for you
README

agent-narrator

Watch the agent work: every step in plain English, with a live time-saved counter.

agent-narrator

Translates every tool call into one plain-English sentence ("Reading the pricing page to find the old numbers") and keeps a running estimate of time saved. /narrate smart uses a model call per step; the default is rule-based and free. /narrate demo plays a scripted session. We built this for training sessions where the audience has never seen an agent work.

Install

In a Claude Code session (2.1.287 or later):

/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install agent-narrator@claude-code-mods

Or load this folder for one session: claude --plugin-dir ./agent-narrator

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
agent-narratorNoNoNoOnly with /narrate smart: the tool name and short fields (path, command), never file contentsOnly to your Claude model

Run claude plugin validate ./agent-narrator to list every event it hooks and every call it makes. See the repository README for the full security notes.

Part of claude-code-mods by OneWave AI. MIT licensed.

Source 3 files
hooks/register.tsx 239 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run, Step } from '../types'
5import { DEMO, duration, elapsed, narrate, testNote } from './narrate'
6
7const PANE = 'agent-narrator'
8const TICK_MS = 120
9const KEEP = 120
10
11const feed = atom({ plugin: 'agent-narrator', key: 'feed' } as const, [] as Step[])
12const run = atom({ plugin: 'agent-narrator', key: 'run' } as const, {
13  isRunning: false,
14  startedAt: null,
15  endedAt: null,
16  steps: 0,
17  savedMinutes: 0,
18} as Run)
19const isSmart = atom({ plugin: 'agent-narrator', key: 'isSmart' } as const, false)
20const isDemo = atom({ plugin: 'agent-narrator', key: 'isDemo' } as const, false)
21
22const NAVY = '#0B1121'
23const BLUE = '#61A5FA'
24const GOLD = '#c2a87e'
25const PAPER = '#E8E2D6'
26const RING = ['◜', '◝', '◞', '◟']
27
28let frame = 0
29let shown = 0
30let demoTimer: { cancel: () => void } | null = null
31
32const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Agent at work' })
33
34const startRun = async ($: EngineInterface) => {
35  const now = await $.clock.now()
36  await update($, run, r => ({ ...r, isRunning: true, startedAt: r.isRunning ? r.startedAt : now, endedAt: null }))
37}
38
39const endRun = async ($: EngineInterface) => {
40  const now = await $.clock.now()
41  await update($, run, r => ({ ...r, isRunning: false, endedAt: now }))
42  $.ui.status(undefined)
43}
44
45/** Turns one model rewrite into a warmer line; the template line stays if the call fails. */
46/** The short fields of a tool input, never file bodies or edit text, capped for the model call. */
47const brief = (input: Record<string, unknown>) => {
48  const keep = ['file_path', 'path', 'pattern', 'command', 'url', 'query', 'description']
49  const picked = Object.fromEntries(
50    keep.filter(k => typeof input[k] === 'string').map(k => [k, String(input[k]).slice(0, 120)]),
51  )
52  return JSON.stringify(picked).slice(0, 400)
53}
54
55const polish = async ($: EngineInterface, id: string, text: string, detail: string) => {
56  const r = await $.model.complete({
57    model: 'haiku',
58    effort: 'low',
59    maxTokens: 60,
60    system:
61      'You narrate an AI agent working, for a non-technical business owner watching. Rewrite the step as one short present-tense sentence, under 12 words, no jargon, no file extensions, no emojis, no quotes.',
62    prompt: `Step: ${text}\nTechnical detail: ${detail.slice(0, 400)}`,
63  })
64  if (!r.isAnswered) return
65  const line = r.text.trim().split('\n')[0]?.replace(/^["']|["']$/g, '')
66  if (!line) return
67  await update($, feed, list => list.map(s => (s.id === id ? { ...s, text: line } : s)))
68}
69
70const begin = async ($: EngineInterface, id: string, tool: string, input: Record<string, unknown>) => {
71  const n = narrate(tool, input)
72  const step: Step = { id, text: n.text, note: null, status: 'running', at: await $.clock.now(), minutes: n.minutes }
73  await update($, feed, list => [...list, step].slice(-KEEP))
74  await update($, run, r => ({ ...r, steps: r.steps + 1 }))
75  $.ui.status(`Agent: ${n.text}`)
76  if (await read($, isSmart)) void polish($, id, n.text, `${tool} ${brief(input)}`).catch(() => undefined)
77  return step
78}
79
80const settle = async ($: EngineInterface, step: Step, isFailed: boolean, stdout: string) => {
81  const tests = testNote(stdout)
82  const note = tests ?? (isFailed ? 'did not work, trying another way' : null)
83  const status: Step['status'] = isFailed ? 'failed' : 'done'
84  await update($, feed, list => list.map(s => (s.id === step.id ? { ...s, status, note } : s)))
85  if (!isFailed || tests) await update($, run, r => ({ ...r, savedMinutes: r.savedMinutes + step.minutes }))
86}
87
88const reset = async ($: EngineInterface) => {
89  demoTimer?.cancel()
90  demoTimer = null
91  shown = 0
92  await update($, feed, () => [])
93  await update($, run, () => ({ isRunning: false, startedAt: null, endedAt: null, steps: 0, savedMinutes: 0 }))
94}
95
96const demo = async ($: EngineInterface) => {
97  await reset($)
98  await update($, isDemo, () => true)
99  await open($)
100  await startRun($)
101  let i = 0
102  let pending: Step | null = null
103  const advance = async () => {
104    if (pending) {
105      const was = DEMO[i - 1]!
106      await settle($, pending, was.isError === true, was.stdout ?? '')
107      pending = null
108    }
109    const next = DEMO[i]
110    if (!next) {
111      await endRun($)
112      await update($, isDemo, () => false)
113      $.ui.toast('Done. Changes tested and saved, ready for your review.', { timeoutMs: 8000 })
114      return
115    }
116    i += 1
117    pending = await begin($, `demo-${i}`, next.tool, next.input)
118    demoTimer = $.clock.after(next.ms, () => void advance().catch(() => undefined))
119  }
120  await advance()
121}
122
123export const register: Register = on => {
124  on('session.start', async ($, e, next) => {
125    await $.command.register({
126      name: 'narrate',
127      description: 'Watch the agent work in plain English: /narrate [smart|demo|reset]',
128    })
129    $.clock.every(TICK_MS, () => {
130      void (async () => {
131        const r = await read($, run)
132        const isCounting = Math.abs(r.savedMinutes - shown) > 0.05
133        if (isCounting) shown += Math.max(0.2, (r.savedMinutes - shown) * 0.12) * Math.sign(r.savedMinutes - shown)
134        if (Math.abs(r.savedMinutes - shown) < 0.2) shown = r.savedMinutes
135        if (!r.isRunning && !isCounting) return
136        frame += 1
137        $.ui.invalidate('ui.render')
138      })().catch(() => undefined)
139    })
140    return next(e)
141  })
142
143  on('command.run', { command: 'narrate' }, async ($, e) => {
144    const arg = e.args.trim().toLowerCase()
145    if (arg === 'smart') {
146      const isOn = !(await read($, isSmart))
147      await update($, isSmart, () => isOn)
148      return { text: isOn ? 'Smart narration on: each step is rewritten by a small model.' : 'Smart narration off.' }
149    }
150    if (arg === 'demo') {
151      await demo($)
152      return { text: 'Playing the demo.' }
153    }
154    if (arg === 'reset') {
155      await reset($)
156      return { text: 'Narration cleared.' }
157    }
158    await $.ui.open({ id: PANE, title: 'Agent at work', focus: true })
159    return { text: 'Narrator open.' }
160  })
161
162  on('prompt.submit', async ($, e, next) => {
163    if (!(await read($, isDemo))) await startRun($)
164    return next(e)
165  })
166
167  on('tool.call', async ($, e, next) => {
168    if (await read($, isDemo)) return next(e)
169    const tool = String(e.tool)
170    const step = await begin($, e.tool_use_id, tool, e as unknown as Record<string, unknown>)
171    const ran = await next(e)
172    const out = ran.result as { stdout?: unknown; stderr?: unknown } | undefined
173    const stdout = typeof out?.stdout === 'string' ? `${out.stdout}\n${String(out.stderr ?? '')}` : ''
174    await settle($, step, ran.deny !== undefined || ran.isError === true, stdout)
175    return ran
176  })
177
178  on('turn.complete', async ($, e, next) => {
179    if (!(await read($, isDemo))) await endRun($)
180    return next(e)
181  })
182
183  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
184    const { Box, Text } = $.ui.resolve(e)
185    const r = await read($, run)
186    const list = await read($, feed)
187    const smart = await read($, isSmart)
188    const now = await $.clock.now()
189    const room = Math.max(3, (e.viewport?.rows ?? 30) - 12)
190    const time = r.startedAt ? elapsed((r.isRunning ? now : (r.endedAt ?? now)) - r.startedAt) : '0:00'
191
192    const row = (s: Step) => {
193      const isLive = s.status === 'running'
194      const mark = isLive ? RING[frame % RING.length]! : s.status === 'failed' ? '×' : '✓'
195      const color = isLive ? BLUE : s.status === 'failed' ? GOLD : PAPER
196      return (
197        <Box key={s.id}>
198          <Box width={3} flexShrink={0}>
199            <Text color={color} bold={isLive}>{mark}</Text>
200          </Box>
201          <Box flexDirection="column" flexGrow={1} flexShrink={1}>
202            <Text bold={isLive} dimColor={!isLive && s.status === 'done'} wrap="wrap">
203              {s.text}
204              {s.note && <Text color={s.status === 'failed' ? GOLD : BLUE}>{`  ·  ${s.note}`}</Text>}
205            </Text>
206          </Box>
207        </Box>
208      )
209    }
210
211    return (
212      <Box flexDirection="column" paddingX={1}>
213        <Box justifyContent="space-between">
214          <Text>
215            {r.isRunning ? (
216              <Text color={BLUE} bold>{`${RING[frame % RING.length]} WORKING`}</Text>
217            ) : (
218              <Text backgroundColor={GOLD} color={NAVY} bold>{r.steps ? ' DONE ' : ' READY '}</Text>
219            )}
220            <Text dimColor>{`   step ${r.steps}   ${time}`}</Text>
221          </Text>
222          {smart && <Text dimColor>smart</Text>}
223        </Box>
224        <Box marginTop={1} marginBottom={1} flexDirection="column">
225          <Text dimColor>ESTIMATED HUMAN TIME SAVED</Text>
226          <Text bold color={GOLD}>{duration(shown)}</Text>
227        </Box>
228        {list.length === 0 && <Text dimColor>Waiting for the agent to start. Ask it to do something, or /narrate demo.</Text>}
229        {list.slice(-room).map(row)}
230        <Box marginTop={1}>
231          <Text dimColor>
232            <Text color={BLUE}>Claude</Text> · working for you
233          </Text>
234        </Box>
235      </Box>
236    )
237  })
238}
239
hooks/narrate.ts 187 lines
1/** Pure narration: a tool call in, one plain-English line for a non-technical viewer out. */
2
3export type Narration = { text: string; minutes: number }
4
5const KIND: Record<string, string> = {
6  tsx: 'screen',
7  jsx: 'screen',
8  vue: 'screen',
9  svelte: 'screen',
10  html: 'page',
11  css: 'styles',
12  scss: 'styles',
13  ts: 'code',
14  js: 'code',
15  mjs: 'code',
16  py: 'code',
17  go: 'code',
18  rb: 'code',
19  rs: 'code',
20  swift: 'code',
21  sql: 'database script',
22  json: 'settings',
23  yaml: 'settings',
24  yml: 'settings',
25  toml: 'settings',
26  env: 'settings',
27  md: 'notes',
28  txt: 'notes',
29  csv: 'data',
30  png: 'image',
31  jpg: 'image',
32  svg: 'graphic',
33}
34
35const GENERIC = new Set(['index', 'main', 'page', 'route', 'layout', 'mod', 'app', 'default'])
36
37/** "src/components/PricingCard.tsx" -> "pricing card" */
38export const wordsOf = (name: string) =>
39  name
40    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
41    .replace(/[-_.]+/g, ' ')
42    .trim()
43    .toLowerCase()
44
45/** "app/pricing/page.tsx" -> "the pricing page"; "auth.test.ts" -> "the auth tests". */
46export const fileLabel = (path: string) => {
47  const parts = path.split(/[\\/]/).filter(Boolean)
48  const file = parts[parts.length - 1] ?? path
49  const dot = file.lastIndexOf('.')
50  const ext = dot > 0 ? file.slice(dot + 1).toLowerCase() : ''
51  let stem = dot > 0 ? file.slice(0, dot) : file
52  const isTest = /\.(test|spec)$/i.test(stem) || /(^|[\\/])(__tests__|tests?)[\\/]/i.test(path)
53  stem = stem.replace(/\.(test|spec)$/i, '')
54  const isGeneric = GENERIC.has(stem.toLowerCase())
55  const parent = parts[parts.length - 2]
56  const base = isGeneric && parent && !parent.startsWith('(') && !parent.startsWith('[') ? parent : stem
57  const name = wordsOf(base) || 'project'
58  if (isTest) return `the ${name} tests`
59  if (file.startsWith('.env')) return 'the private settings'
60  const kind = KIND[ext] ?? 'file'
61  if (isGeneric && ext === 'tsx' && parent) return `the ${name} page`
62  return `the ${name} ${kind}`
63}
64
65const quote = (text: string, max = 40) => {
66  const clean = text.replace(/\s+/g, ' ').trim()
67  return `"${clean.length > max ? `${clean.slice(0, max - 1)}…` : clean}"`
68}
69
70const hostOf = (url: string) => {
71  const m = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
72  return m ? m[1]!.replace(/^www\./, '') : 'a web page'
73}
74
75/** A shell command, explained. */
76export const narrateCommand = (command: string): Narration => {
77  const c = command.trim()
78  const rules: [RegExp, Narration][] = [
79    [/\b(test|jest|vitest|pytest|mocha|go test|cargo test|bun test)\b/i, { text: 'Running the test suite', minutes: 4 }],
80    [/\bgit\s+commit\b/, { text: 'Saving a checkpoint of the work', minutes: 2 }],
81    [/\bgit\s+push\b/, { text: 'Publishing the changes', minutes: 2 }],
82    [/\bgit\s+(status|diff|log|show)\b/, { text: 'Reviewing what has changed so far', minutes: 2 }],
83    [/\bgit\s+(checkout|switch|branch)\b/, { text: 'Setting up a separate workspace for this change', minutes: 1 }],
84    [/\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip\s+install\b|\bbrew\s+install\b/, { text: 'Installing the building blocks it needs', minutes: 3 }],
85    [/\b(run\s+)?build\b|\btsc\b/, { text: 'Building the app to make sure it all fits together', minutes: 4 }],
86    [/\b(run\s+)?(dev|start|serve)\b/, { text: 'Starting the app to try it out', minutes: 2 }],
87    [/\b(lint|eslint|prettier|ruff|black)\b/, { text: 'Tidying up and checking code style', minutes: 2 }],
88    [/\b(vercel|deploy|fly deploy|netlify)\b/, { text: 'Deploying the update live', minutes: 5 }],
89    [/\b(curl|wget|http)\b/, { text: 'Checking a live web address', minutes: 2 }],
90    [/\b(psql|supabase|prisma|migrate)\b/, { text: 'Working with the database', minutes: 5 }],
91    [/^(ls|cat|head|tail|find|tree|wc|pwd|grep|rg|sed -n)\b/, { text: 'Looking around the project', minutes: 1 }],
92    [/^(mkdir|cp|mv|touch)\b/, { text: 'Organizing project files', minutes: 1 }],
93  ]
94  for (const [re, n] of rules) if (re.test(c)) return n
95  return { text: 'Running a quick command', minutes: 1 }
96}
97
98const field = (input: Record<string, unknown>, key: string) => {
99  const v = input[key]
100  return typeof v === 'string' ? v : ''
101}
102
103/** One tool call, explained, with a rough estimate of the minutes a person would spend on it. */
104export const narrate = (tool: string, input: Record<string, unknown>): Narration => {
105  const path = field(input, 'file_path') || field(input, 'notebook_path') || field(input, 'path')
106  switch (tool) {
107    case 'Read':
108      return { text: `Reading ${fileLabel(path)}`, minutes: 2 }
109    case 'Edit':
110    case 'MultiEdit':
111    case 'NotebookEdit':
112      return { text: `Saving changes to ${fileLabel(path)}`, minutes: 6 }
113    case 'Write':
114      return { text: `Creating ${fileLabel(path)}`, minutes: 10 }
115    case 'Bash':
116      return narrateCommand(field(input, 'command'))
117    case 'Grep':
118      return { text: `Searching the project for ${quote(field(input, 'pattern'))}`, minutes: 2 }
119    case 'Glob':
120      return { text: 'Finding the files involved', minutes: 1 }
121    case 'WebFetch':
122      return { text: `Reading ${hostOf(field(input, 'url'))}`, minutes: 3 }
123    case 'WebSearch':
124      return { text: `Researching ${quote(field(input, 'query'))} on the web`, minutes: 5 }
125    case 'Agent':
126    case 'Task': {
127      const what = field(input, 'description')
128      return { text: what ? `Bringing in a helper to ${what.charAt(0).toLowerCase()}${what.slice(1)}` : 'Bringing in a helper', minutes: 15 }
129    }
130    case 'TodoWrite':
131      return { text: 'Updating the plan', minutes: 1 }
132    case 'AskUserQuestion':
133      return { text: 'Checking a decision with you', minutes: 0 }
134  }
135  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
136  if (mcp) {
137    const server = wordsOf(mcp[1]!.replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, ''))
138      .split(' ')
139      .map(w => (w ? w[0]!.toUpperCase() + w.slice(1) : w))
140      .join(' ')
141    const action = wordsOf(mcp[2]!.replace(/^[a-z]+_(?=[a-z]+_)/, ''))
142    return { text: `Using ${server} to ${action}`, minutes: 3 }
143  }
144  return { text: `Using ${wordsOf(tool)}`, minutes: 1 }
145}
146
147/** Counts from a test run's output, as a short note: "42 passed" / "40 passed, 2 failed". */
148export const testNote = (output: string) => {
149  const top = (re: RegExp) => {
150    let n: number | null = null
151    for (const m of output.matchAll(re)) n = Math.max(n ?? 0, Number(m[1]))
152    return n
153  }
154  const passed = top(/(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?\b/gi)
155  const failed = top(/(\d+)\s+(?:tests?\s+)?fail(?:ed|ing|ures?)?\b/gi)
156  if (passed === null && failed === null) return null
157  return failed ? `${passed ?? 0} passed, ${failed} failed` : `${passed} passed`
158}
159
160/** "3h 05m" / "42m" */
161export const duration = (minutes: number) => {
162  const m = Math.max(0, Math.round(minutes))
163  return m >= 60 ? `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`
164}
165
166export const elapsed = (ms: number) => {
167  const s = Math.max(0, Math.floor(ms / 1000))
168  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
169}
170
171/** The scripted demo: tool, input, a result's stdout, the pause before it. */
172export const DEMO: readonly { tool: string; input: Record<string, unknown>; stdout?: string; isError?: boolean; ms: number }[] = [
173  { tool: 'Glob', input: { pattern: 'app/**/*.tsx' }, ms: 900 },
174  { tool: 'Read', input: { file_path: 'app/pricing/page.tsx' }, ms: 1200 },
175  { tool: 'Read', input: { file_path: 'components/CheckoutForm.tsx' }, ms: 1100 },
176  { tool: 'Grep', input: { pattern: 'applyDiscount' }, ms: 1000 },
177  { tool: 'mcp__claude_ai_Stripe__list_prices', input: {}, ms: 1400 },
178  { tool: 'Edit', input: { file_path: 'components/CheckoutForm.tsx' }, ms: 1600 },
179  { tool: 'Edit', input: { file_path: 'lib/pricing-rules.ts' }, ms: 1400 },
180  { tool: 'Write', input: { file_path: 'tests/checkout.test.ts' }, ms: 1500 },
181  { tool: 'Bash', input: { command: 'npm test' }, stdout: 'Tests: 2 failed, 40 passed, 42 total', isError: true, ms: 2200 },
182  { tool: 'Edit', input: { file_path: 'lib/pricing-rules.ts' }, ms: 1300 },
183  { tool: 'Bash', input: { command: 'npm test' }, stdout: 'Tests: 42 passed, 42 total', ms: 2000 },
184  { tool: 'Bash', input: { command: 'npm run build' }, ms: 1800 },
185  { tool: 'Bash', input: { command: 'git commit -m "Checkout: apply volume discounts"' }, ms: 900 },
186]
187
types/index.d.ts 23 lines
1export type Step = {
2  id: string
3  text: string
4  note: string | null
5  status: 'running' | 'done' | 'failed'
6  at: number
7  minutes: number
8}
9
10export type Run = {
11  isRunning: boolean
12  startedAt: number | null
13  endedAt: number | null
14  steps: number
15  savedMinutes: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'agent-narrator': { feed: Step[]; run: Run; isSmart: boolean; isDemo: boolean }
21  }
22}
23