SLOPSHOPPER

ops-pane

Observe-only ops pane: files touched, recent tool calls, subagents, context and tokens; /ops toggles it, /ops-export writes the tool-call ledger as JSONL.

panebandguardcommandtimeragents
v0.1.0no licenseupdated 2026-10-16abteenz/ops-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ops-pane
│ ┃ Ops ✕ › fix the failing auth test and add an audit log call │ ┃ CONTEXT │ ┃ 49% of 200.0k (97.4k tokens) ▄ ⏺ Read(src/auth.ts) │ ┃ session in 97.4k (91.0k cache read, 4.3k ca… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ FILES ⎿ Added 2 lines, removed 1 line │ ┃ Read 1 file, 1× ⏺ Bash(bun test) │ ┃ src/auth.ts ⎿ 3 pass, 1 fail │ ┃ Edited 3 files, 3× │ ┃ src/cache.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ src/audit.ts │ ┃ src/auth.ts ✻ Worked for 42s · done 4:20 PM │ ┃ Created 0 files, 0× │ ┃ › /ops │ ┃ TOOL CALLS last 9 of 9 ⎿ ops-pane: Ops pane opened. │ ┃ 08:53:20 ✓ Bash cat .env │ ┃ 08:53:20 ✓ Bash rm -rf build && git … │ ┃ 08:53:20 ✓ Bash git status --porcela… │ ┃ 08:53:20 ✗ Bash bun test │ ┃ 08:53:20 ✓ Write src/cache.ts │ ┃ 08:53:20 ✓ Write src/audit.ts │ ┃ 08:53:20 ✓ Edit src/auth.ts │ ┃ 08:53:20 ✓ Grep refresh\( │ ┃ 08:53:20 ✓ Read src/auth.ts │ ┃ │ ┃ SUBAGENTS 0 launched · 0 running ctx 49% · 9 tool calls · 0 subagents active ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 49% · 9 tool calls · 0 subagents active
Pane · Ops
CONTEXT 49% of 200.0k (97.4k tokens) ▄ session in 97.4k (91.0k cache read, 4.3k cache write) · out… FILES Read 1 file, 1× src/auth.ts Edited 3 files, 3× src/cache.ts src/audit.ts src/auth.ts Created 0 files, 0× TOOL CALLS last 9 of 9 08:53:20 ✓ Bash cat .env 08:53:20 ✓ Bash rm -rf build && git push --force ori… 08:53:20 ✓ Bash git status --porcelain 08:53:20 ✗ Bash bun test 08:53:20 ✓ Write src/cache.ts 08:53:20 ✓ Write src/audit.ts 08:53:20 ✓ Edit src/auth.ts 08:53:20 ✓ Grep refresh\( 08:53:20 ✓ Read src/auth.ts SUBAGENTS 0 launched · 0 running none yet
README

ops-pane

An observe-only Claude Code mod that shows what the session is doing:

  • Files touched: grouped as Read / Edited / Created, with counts, most recent first
  • Tool calls: a rolling log of the last 15 (tool name and a short argument summary)
  • Subagents: how many were launched, which are still running, and the task each was given
  • Context and tokens: context window use after each request, and the session's input/output tokens

A one-line band above the prompt shows ctx % · tool calls · subagents active.

It only observes. It never changes, approves or blocks a tool call, and it makes no network requests or model calls.

Commands

  • /ops opens or closes the pane. In fullscreen at 110 columns or wider it docks beside the transcript; on a narrower terminal it opens above the prompt.
  • /ops-export writes the session's tool-call ledger as JSONL to .claude/logs/<session-id>.jsonl in the current repo (or in the working directory outside a git repo).

Install

At the prompt of a Claude Code terminal session:

/plugin install ops-pane --marketplace abteenz/ops-pane

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install ops-pane --marketplace abteenz/ops-pane

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with abteenz/ops-pane, then Add plugin.

Source 2 files
hooks/register.tsx 404 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CallStatus, FileKind, FileTouch, LedgerEntry, Subagent, Usage } from '../types'
5
6// Observe only: every hook passes `e` to `next` unchanged and returns what
7// `next` answered, and every bookkeeping step is wrapped so a failure here
8// can never refuse, rewrite or hold up a tool call, a spawn or a turn.
9
10const PANE = 'ops'
11const TITLE = 'Ops'
12const LOG_ROWS = 15
13const FILE_ROWS = 5
14const LEDGER_CAP = 5000
15const HISTORY_CAP = 40
16const ARG_CAP = 1000
17
18const files = atom({ plugin: 'ops-pane', key: 'files' } as const, [])
19const ledger = atom({ plugin: 'ops-pane', key: 'ledger' } as const, [])
20const total = atom({ plugin: 'ops-pane', key: 'total' } as const, 0)
21const agents = atom({ plugin: 'ops-pane', key: 'agents' } as const, [])
22const EMPTY_USAGE: Usage = { history: [], input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
23const usage = atom({ plugin: 'ops-pane', key: 'usage' } as const, EMPTY_USAGE)
24
25const LIVE = new Set(['pending', 'running', 'waiting'])
26const SPARK = '▁▂▃▄▅▆▇█'
27
28const quietly = async (work: () => Promise<unknown>) => {
29  try {
30    await work()
31  } catch {
32    // An observer that fails stays silent: the session goes on untouched.
33  }
34}
35
36const cut = (text: string, max: number) =>
37  text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text
38
39const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
40
41const short = (n: number) =>
42  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
43
44const clockTime = (ms: number) => {
45  const d = new Date(ms)
46  return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => `${n}`.padStart(2, '0')).join(':')
47}
48
49const relative = (path: string, cwd: string) =>
50  cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
51
52const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
53
54const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestMeta'])
55
56/** The tool's own arguments, long strings cut so the ledger stays small. */
57const argsOf = (input: Record<string, unknown>) => {
58  const args: Record<string, unknown> = {}
59  for (const [k, v] of Object.entries(input)) {
60    if (RESERVED.has(k)) continue
61    args[k] =
62      typeof v === 'string' && v.length > ARG_CAP
63        ? `${v.slice(0, ARG_CAP)}…(+${v.length - ARG_CAP} chars)`
64        : v
65  }
66  return args
67}
68
69const summarize = (tool: string, a: Record<string, unknown>, cwd: string) => {
70  const path = str(a.file_path) ?? str(a.notebook_path)
71  if (tool === 'Bash') return cut(oneLine(str(a.command) ?? ''), 60)
72  if (path !== undefined) return relative(path, cwd)
73  if (tool === 'Agent') return cut(`${str(a.subagent_type) ?? 'general-purpose'}: ${str(a.description) ?? ''}`, 60)
74  if (tool === 'TodoWrite' && Array.isArray(a.todos)) return `${a.todos.length} todos`
75  const first =
76    str(a.pattern) ?? str(a.url) ?? str(a.query) ?? str(a.skill) ??
77    Object.values(a).find((v): v is string => typeof v === 'string')
78  return first === undefined ? '' : cut(oneLine(first), 60)
79}
80
81const touchKind = (tool: string, result: unknown): FileKind | undefined => {
82  if (tool === 'Read') return 'read'
83  if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'edited'
84  if (tool === 'Write') {
85    const type = (result as { type?: unknown } | undefined)?.type
86    return type === 'create' ? 'created' : 'edited'
87  }
88  return undefined
89}
90
91const agentEnd = (reason: string) =>
92  reason === 'answer' ? 'completed' : reason === 'aborted' ? 'killed' : 'failed'
93
94/** Brings each tracked subagent's status up to what the engine lists now. */
95const reconcile = async ($: EngineInterface) => {
96  const listed = await $.agent.list()
97  const byId = new Map(listed.map(a => [a.id, a.status]))
98  const now = await $.clock.now()
99  await update($, agents, list =>
100    list.map(a => {
101      const status = byId.get(a.id)
102      if (status === undefined || status === a.status) return a
103      return { ...a, status, endedAt: LIVE.has(status) ? undefined : (a.endedAt ?? now) }
104    }),
105  )
106}
107
108export const register: Register = on => {
109  // The module's own copy of the working directory, for shortening paths.
110  let cwd = ''
111
112  on('session.start', async ($, e, next) => {
113    await quietly(async () => {
114      cwd = await $.session.cwd()
115    })
116    await $.command.register({ name: 'ops', description: 'Toggle the ops pane (files, tool calls, subagents, context)' })
117    await $.command.register({
118      name: 'ops-export',
119      description: 'Write this session’s tool-call ledger as JSONL to .claude/logs/<session-id>.jsonl',
120    })
121    // While any subagent is live, follow its status from the engine's list.
122    $.clock.every(3000, async () => {
123      await quietly(async () => {
124        const list = await read($, agents)
125        if (list.some(a => LIVE.has(a.status))) await reconcile($)
126      })
127    })
128
129    return next(e)
130  })
131
132  on('session.end', async ($, e, next) => {
133    if (e.reason === 'clear') {
134      await quietly(async () => {
135        await update($, files, () => [])
136        await update($, ledger, () => [])
137        await update($, total, () => 0)
138        await update($, agents, () => [])
139        await update($, usage, () => EMPTY_USAGE)
140      })
141    }
142    return next(e)
143  })
144
145  on('tool.call', async ($, e, next) => {
146    const tool = String(e.tool)
147    const id = e.tool_use_id ?? `call-${Math.random().toString(36).slice(2)}`
148    const args = argsOf(e as unknown as Record<string, unknown>)
149
150    await quietly(async () => {
151      const entry: LedgerEntry = {
152        id,
153        tool,
154        ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
155        summary: summarize(tool, args, cwd),
156        input: args,
157        startedAt: await $.clock.now(),
158        status: 'running',
159      }
160      await update($, ledger, list => [...list, entry].slice(-LEDGER_CAP))
161      await update($, total, n => n + 1)
162    })
163
164    const ran = await next(e)
165
166    await quietly(async () => {
167      const status: CallStatus =
168        ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
169      const endedAt = await $.clock.now()
170      const error = ran.deny ?? (ran.isError === true ? cut(ran.text ?? '', 300) : undefined)
171      await update($, ledger, list =>
172        list.map(one =>
173          one.id === id ? { ...one, status, endedAt, ...(error === undefined ? {} : { error }) } : one,
174        ),
175      )
176
177      const path = str(args.file_path) ?? str(args.notebook_path)
178      const kind = status === 'ok' && path !== undefined ? touchKind(tool, ran.result) : undefined
179      if (path === undefined || kind === undefined) return
180      await update($, files, list => {
181        const found = list.find(f => f.path === path && f.kind === kind)
182        const touch: FileTouch = { path, kind, count: (found?.count ?? 0) + 1, lastAt: endedAt }
183        return [touch, ...list.filter(f => f !== found)]
184      })
185    })
186
187    return ran
188  })
189
190  on('agent.spawn', async ($, e, next) => {
191    const spawned = await next(e)
192
193    await quietly(async () => {
194      if (spawned.agentId === undefined) return
195      const agent: Subagent = {
196        id: spawned.agentId,
197        type: e.subagentType,
198        description: e.description,
199        prompt: e.prompt,
200        status: 'running',
201        startedAt: await $.clock.now(),
202      }
203      await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent])
204    })
205
206    return spawned
207  })
208
209  on('turn.complete', async ($, e, next) => {
210    await quietly(async () => {
211      const used = e.usage
212      if (used !== undefined) {
213        await update($, usage, u => ({
214          ...u,
215          input: u.input + used.input_tokens,
216          output: u.output + used.output_tokens,
217          cacheRead: u.cacheRead + used.cache_read_input_tokens,
218          cacheWrite: u.cacheWrite + used.cache_creation_input_tokens,
219        }))
220      }
221      const agentId = e.agentId
222      if (agentId === undefined) return
223      const now = await $.clock.now()
224      await update($, agents, list =>
225        list.map(a =>
226          a.id === agentId && LIVE.has(a.status) ? { ...a, status: agentEnd(e.reason), endedAt: now } : a,
227        ),
228      )
229      // A teammate goes idle rather than ending: the engine's list says so.
230      await reconcile($)
231    })
232
233    return next(e)
234  })
235
236  on('session.measure', async ($, e, next) => {
237    await quietly(async () => {
238      const { percent, tokens, window } = e.context
239      if (!e.changed.includes('context') || percent === undefined) return
240      await update($, usage, u => ({
241        ...u,
242        contextPercent: percent,
243        contextTokens: tokens,
244        window,
245        history: [...u.history, percent].slice(-HISTORY_CAP),
246      }))
247    })
248
249    return next(e)
250  })
251
252  on('command.run', { command: 'ops' }, async $ => {
253    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
254    if (isOpen) {
255      await $.ui.close({ id: PANE })
256      return { text: 'Ops pane closed.' }
257    }
258    const opened = await $.ui.open({ id: PANE, title: TITLE })
259    return { text: opened.isPlaced ? 'Ops pane opened.' : `Ops pane opened, waiting: ${opened.reason}` }
260  })
261
262  on('command.run', { command: 'ops-export' }, async $ => {
263    const entries = await read($, ledger)
264    const count = await read($, total)
265    const id = await $.session.id()
266    const root = (await $.session.repo())?.root ?? (await $.session.cwd())
267    const path = `${root}/.claude/logs/${id}.jsonl`
268    const text = entries.map(one => JSON.stringify(one)).join('\n') + (entries.length > 0 ? '\n' : '')
269    await $.fs.write(path, text)
270    const dropped = count - entries.length
271    return {
272      text:
273        `Wrote ${entries.length} tool calls to ${path}` +
274        (dropped > 0 ? ` (the oldest ${dropped} fell out of the ${LEDGER_CAP}-call ledger).` : '.'),
275    }
276  })
277
278  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279    if (e.props.hasSurvey) return next(e)
280    const u = await read($, usage)
281    const calls = await read($, total)
282    const live = (await read($, agents)).filter(a => LIVE.has(a.status)).length
283    if (u.contextPercent === undefined && calls === 0) return next(e)
284
285    const { Text } = $.ui.resolve(e)
286    const ctx = u.contextPercent === undefined ? 'ctx –' : `ctx ${u.contextPercent}%`
287    const ctxColor = (u.contextPercent ?? 0) >= 80 ? 'error' : (u.contextPercent ?? 0) >= 60 ? 'warning' : undefined
288
289    return (
290      <Text wrap="truncate-end" dimColor>
291        <Text color={ctxColor}>{ctx}</Text>
292        {` · ${calls} tool call${calls === 1 ? '' : 's'} · ${live} subagent${live === 1 ? '' : 's'} active`}
293      </Text>
294    )
295  })
296
297  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
298    const { Box, Text } = $.ui.resolve(e)
299    const width = Math.max(20, e.props.bodyColumns)
300    const u = await read($, usage)
301    const touched = await read($, files)
302    const calls = await read($, ledger)
303    const callCount = await read($, total)
304    const subs = await read($, agents)
305
306    const header = (label: string, extra: string) => (
307      <Text wrap="truncate-end">
308        <Text bold color="claude">{label}</Text>
309        <Text dimColor>{extra}</Text>
310      </Text>
311    )
312
313    // Context and tokens
314    const spark = u.history
315      .slice(-Math.max(5, Math.min(HISTORY_CAP, width - 40)))
316      .map(p => SPARK[Math.min(SPARK.length - 1, Math.floor((p / 100) * SPARK.length))])
317      .join('')
318    const inTotal = u.input + u.cacheRead + u.cacheWrite
319    const ctxLine =
320      u.contextPercent === undefined
321        ? 'no reading yet'
322        : `${u.contextPercent}% of ${short(u.window ?? 0)} (${short(u.contextTokens ?? 0)} tokens)`
323    const pctColor = (u.contextPercent ?? 0) >= 80 ? 'error' : (u.contextPercent ?? 0) >= 60 ? 'warning' : 'success'
324
325    // Files, grouped, most recent first
326    const groups: [FileKind, string][] = [['read', 'Read'], ['edited', 'Edited'], ['created', 'Created']]
327    const fileRows = groups.map(([kind, label]) => {
328      const list = touched.filter(f => f.kind === kind).sort((a, b) => b.lastAt - a.lastAt)
329      const times = list.reduce((n, f) => n + f.count, 0)
330      return (
331        <Box flexDirection="column">
332          <Text wrap="truncate-end">
333            <Text bold>{label.padEnd(8)}</Text>
334            <Text dimColor>{list.length} file{list.length === 1 ? '' : 's'}, {times}×</Text>
335          </Text>
336          {list.slice(0, FILE_ROWS).map(f => (
337            <Text wrap="truncate-start">
338              {'  '}
339              {relative(f.path, cwd)}
340              <Text dimColor>{f.count > 1 ? ` ×${f.count}` : ''}</Text>
341            </Text>
342          ))}
343          {list.length > FILE_ROWS && <Text dimColor>{`  +${list.length - FILE_ROWS} more`}</Text>}
344        </Box>
345      )
346    })
347
348    // Last tool calls, newest first
349    const mark = (s: CallStatus) =>
350      s === 'running' ? <Text color="warning">…</Text>
351        : s === 'ok' ? <Text color="success">✓</Text>
352        : s === 'denied' ? <Text color="warning">⊘</Text>
353        : <Text color="error">✗</Text>
354    const toolWidth = 12
355    const callRows = calls.slice(-LOG_ROWS).reverse().map(c => (
356      <Text wrap="truncate-end">
357        <Text dimColor>{clockTime(c.startedAt)} </Text>
358        {mark(c.status)}
359        {' '}
360        <Text bold>{cut(c.tool, toolWidth).padEnd(toolWidth)}</Text>
361        <Text dimColor>{c.agentId === undefined ? '' : '↳ '}</Text>
362        {c.summary}
363      </Text>
364    ))
365
366    // Subagents: live ones first, then the most recent finished
367    const live = subs.filter(a => LIVE.has(a.status))
368    const done = subs.filter(a => !LIVE.has(a.status)).reverse()
369    const agentRows = [...live, ...done].slice(0, 8).map(a => (
370      <Box flexDirection="column">
371        <Text wrap="truncate-end">
372          {LIVE.has(a.status) ? <Text color="warning">● </Text> : <Text dimColor>○ </Text>}
373          <Text bold>{a.type}</Text>
374          <Text dimColor>{` ${a.status} · `}</Text>
375          {a.description}
376        </Text>
377        <Text wrap="truncate-end" dimColor>{`    ${oneLine(a.prompt)}`}</Text>
378      </Box>
379    ))
380
381    return (
382      <Box flexDirection="column">
383        {header('CONTEXT ', '')}
384        <Text wrap="truncate-end">
385          <Text color={pctColor}>{ctxLine}</Text>
386          <Text dimColor>{spark === '' ? '' : `  ${spark}`}</Text>
387        </Text>
388        <Text wrap="truncate-end" dimColor>
389          {`session in ${short(inTotal)} (${short(u.cacheRead)} cache read, ${short(u.cacheWrite)} cache write) · out ${short(u.output)}`}
390        </Text>
391        <Text> </Text>
392        {header('FILES ', `${touched.length === 0 ? 'none yet' : ''}`)}
393        {touched.length > 0 && fileRows}
394        <Text> </Text>
395        {header('TOOL CALLS ', `last ${Math.min(LOG_ROWS, calls.length)} of ${callCount}`)}
396        {callRows.length === 0 ? <Text dimColor>none yet</Text> : callRows}
397        <Text> </Text>
398        {header('SUBAGENTS ', `${subs.length} launched · ${live.length} running`)}
399        {agentRows.length === 0 ? <Text dimColor>none yet</Text> : agentRows}
400      </Box>
401    )
402  })
403}
404
types/index.d.ts 54 lines
1export type FileKind = 'read' | 'edited' | 'created'
2
3/** One file under one kind of touch; a file created then edited has two. */
4export type FileTouch = { path: string; kind: FileKind; count: number; lastAt: number }
5
6export type CallStatus = 'running' | 'ok' | 'error' | 'denied'
7
8/** One tool call, as the ledger keeps and /ops-export writes it. */
9export type LedgerEntry = {
10  id: string
11  tool: string
12  agentId?: string
13  summary: string
14  input: Record<string, unknown>
15  startedAt: number
16  endedAt?: number
17  status: CallStatus
18  error?: string
19}
20
21export type Subagent = {
22  id: string
23  type: string
24  description: string
25  prompt: string
26  status: string
27  startedAt: number
28  endedAt?: number
29}
30
31export type Usage = {
32  contextPercent?: number
33  contextTokens?: number
34  window?: number
35  /** Context % at each reading the engine raised, oldest first. */
36  history: number[]
37  input: number
38  output: number
39  cacheRead: number
40  cacheWrite: number
41}
42
43declare module 'claude-code' {
44  interface PluginState {
45    'ops-pane': {
46      files: FileTouch[]
47      ledger: LedgerEntry[]
48      total: number
49      agents: Subagent[]
50      usage: Usage
51    }
52  }
53}
54