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.

An observe-only Claude Code mod that shows what the session is doing:
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.
/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).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.
hooks/register.tsx 404 lines1import { 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}
404types/index.d.ts 54 lines1export 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