A live pane listing every tool call of the session, with its outcome and duration

A column beside Claude when Claude Code runs inside herdr: three panes to the right of Claude's.
~/.claude/workflow-guide.md.~/.claude/agent-monitor/events.jsonl, written by the mods plugin's monitor hooks (../monitor). Shift+Tab here replaces zsh's reverse menu-complete.Outside herdr, or beside a Claude pane narrower than 144 columns, an Activity pane opens inside Claude instead; /activity opens the column anyway, or remakes what was closed of it. Leaving Claude closes the column; /clear keeps it. Design and decisions: docs/2026-10-18-herdr-column-design.md.
It is a function-hooks mod, not part of the mods plugin: load it from this folder by adding its path to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json env (paths separated by :), then start Claude Code again:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-mods/activity-pane" } }
Needs node (the viewers) and python3 (the Shell pane), both stdlib only.
bash activity-pane/test.sh: the Shell pane's views and the wrapper in a real pty (Python), the viewers' drawing (node --test), and the hooks module with herdr mocked (claude plugin test).
hooks/register.tsx 381 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ActivityCall, CallState, Flow } from '../types'
5import { flowsOfStep, flowsOfTool, hostOfRemote, inputOf } from './flows'
6import type { Draft } from './flows'
7import {
8 FLOW_ROWS,
9 MIN_COLUMNS,
10 TOOLS_ROWS,
11 feedPathOf,
12 paneIdOf,
13 rectOf,
14 ratioKeeping,
15 ratioLeaving,
16 ratioText,
17 ratiosOf,
18 shellCommand,
19 viewerCommand,
20} from './herdr'
21import type { Column } from './herdr'
22
23const PANE = 'activity'
24const TITLE = 'Activity'
25const KEEP = 500
26const calls = atom({ plugin: 'activity-pane', key: 'calls' } as const, [])
27const flows = atom({ plugin: 'activity-pane', key: 'flows' } as const, [])
28
29// Catppuccin Mocha, herdr's default theme, so the pane matches the workspace around it.
30const MOCHA = {
31 blue: '#89b4fa',
32 mauve: '#cba6f7',
33 green: '#a6e3a1',
34 yellow: '#f9e2af',
35 peach: '#fab387',
36 red: '#f38ba8',
37 subtext0: '#a6adc8',
38 overlay1: '#7f849c',
39 overlay0: '#6c7086',
40}
41
42const MARK: Record<CallState, string> = { running: '…', done: '✓', failed: '✗', denied: '⊘' }
43const COLOR: Record<CallState, string> = {
44 running: MOCHA.yellow,
45 done: MOCHA.green,
46 failed: MOCHA.red,
47 denied: MOCHA.peach,
48}
49
50// The one argument worth showing for a tool: its command, file, pattern or target.
51const detailOf = (e: object): string => {
52 const args = e as Record<string, unknown>
53 const picked =
54 args.command ?? args.file_path ?? args.notebook_path ?? args.pattern ??
55 args.url ?? args.query ?? args.description ?? args.skill ?? ''
56 const text = String(picked).replace(/\s+/g, ' ').trim()
57
58 return picked === args.file_path || picked === args.notebook_path
59 ? (text.split('/').pop() ?? text)
60 : text
61}
62
63const took = (ms: number) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
64
65// The herdr pane Claude runs in, and where the column beside it is fed from.
66type Herdr = { bin: string; pane: string; root: string; feed: string }
67
68// What this module keeps between hooks: the column's herdr, the model API's
69// name, and the feed write under way, if any.
70type Live = { herdr?: Herdr; api: string; isDirty: boolean; writing?: Promise<void>; count: number }
71
72// Session ends that end the process too; a /clear or a resume goes on beside the same column.
73const EXITS = new Set(['prompt_input_exit', 'logout', 'other'])
74
75async function apiOf($: EngineInterface) {
76 const [base, bedrock, vertex] = await Promise.all([
77 $.env.get('ANTHROPIC_BASE_URL'),
78 $.env.get('CLAUDE_CODE_USE_BEDROCK'),
79 $.env.get('CLAUDE_CODE_USE_VERTEX'),
80 ])
81 const isOn = (flag: string | undefined) => flag === '1' || flag === 'true'
82
83 if (isOn(bedrock)) return 'Amazon Bedrock'
84 if (isOn(vertex)) return 'Google Vertex AI'
85
86 return (base === undefined ? undefined : /^\w+:\/\/([^/:]+)/.exec(base)?.[1]) ?? 'api.anthropic.com'
87}
88
89async function herdrOf($: EngineInterface, root: string): Promise<Herdr | undefined> {
90 const [isHerdr, pane, bin, tmp, home] = await Promise.all([
91 $.env.get('HERDR_ENV'),
92 $.env.get('HERDR_PANE_ID'),
93 $.env.get('HERDR_BIN_PATH'),
94 $.env.get('TMPDIR'),
95 $.env.get('HOME'),
96 ])
97 if (isHerdr !== '1' || pane === undefined || pane === '') return undefined
98
99 // macOS gives each user a private TMPDIR; without one, ~/.claude rather than the shared /tmp
100 return { bin: bin || 'herdr', pane, root, feed: feedPathOf(tmp || `${home}/.claude/activity`, pane) }
101}
102
103async function herdrRun($: EngineInterface, herdr: Herdr, args: string[]) {
104 const ran = await $.process.run([herdr.bin, ...args], { timeoutMs: 5_000 })
105 if (ran.exitCode !== 0) {
106 throw new Error(`herdr ${args.slice(0, 2).join(' ')}: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
107 }
108
109 return ran.stdout
110}
111
112const isAlive = ($: EngineInterface, herdr: Herdr, pane: string) =>
113 herdrRun($, herdr, ['pane', 'get', pane]).then(() => true, () => false)
114
115const close = ($: EngineInterface, herdr: Herdr, pane: string) =>
116 herdrRun($, herdr, ['pane', 'close', pane]).then(() => undefined, () => undefined)
117
118async function sizeOf($: EngineInterface, herdr: Herdr, pane: string) {
119 return rectOf(await herdrRun($, herdr, ['pane', 'layout', '--pane', pane]), pane) ?? { width: 0, height: 40 }
120}
121
122const heightIn = async ($: EngineInterface, herdr: Herdr, pane: string) => (await sizeOf($, herdr, pane)).height
123
124async function split($: EngineInterface, herdr: Herdr, pane: string, direction: 'right' | 'down', ratio: number) {
125 const args = ['pane', 'split', '--pane', pane, '--direction', direction, '--ratio', ratioText(ratio), '--cwd', herdr.root, '--no-focus']
126
127 return paneIdOf(await herdrRun($, herdr, args))
128}
129
130async function startViewer($: EngineInterface, herdr: Herdr, pane: string, viewer: 'flow' | 'tools') {
131 await herdrRun($, herdr, ['pane', 'rename', pane, viewer === 'flow' ? 'Data flow' : 'Tools'])
132 await herdrRun($, herdr, ['pane', 'run', pane, viewerCommand($.plugin.root, viewer, herdr.feed)])
133}
134
135const columnKey = (herdr: Herdr) => `column:${herdr.pane}`
136
137// Splits Claude's pane right, then that pane down twice: data flow, shell, tools.
138async function build($: EngineInterface, herdr: Herdr): Promise<Column> {
139 const made: string[] = []
140 try {
141 const ratios = ratiosOf(await heightIn($, herdr, herdr.pane))
142 const flow = await split($, herdr, herdr.pane, 'right', 0.5)
143 made.push(flow)
144 const shell = await split($, herdr, flow, 'down', ratios.flow)
145 made.push(shell)
146 const tools = await split($, herdr, shell, 'down', ratios.tools)
147 made.push(tools)
148
149 await herdrRun($, herdr, ['pane', 'rename', shell, 'Shell'])
150 await herdrRun($, herdr, ['pane', 'run', shell, shellCommand($.plugin.root, herdr.feed)])
151 await Promise.all([startViewer($, herdr, flow, 'flow'), startViewer($, herdr, tools, 'tools')])
152
153 return { flow, shell, tools }
154 } catch (error) {
155 await Promise.all(made.map(pane => close($, herdr, pane)))
156 throw error
157 }
158}
159
160// Opens the column beside Claude, or makes again what is gone of it. A shell
161// still there is kept whatever else is missing: it may be running something.
162async function openColumn($: EngineInterface, herdr: Herdr) {
163 const known = (await $.store.get(columnKey(herdr))) as Column | undefined
164 const [hasFlow, hasShell, hasTools] =
165 known === undefined
166 ? [false, false, false]
167 : await Promise.all([known.flow, known.shell, known.tools].map(pane => isAlive($, herdr, pane)))
168
169 let column: Column
170 if (known !== undefined && hasShell) {
171 column = { ...known }
172 if (!hasTools) {
173 column.tools = await split($, herdr, column.shell, 'down', ratioLeaving(await heightIn($, herdr, column.shell), TOOLS_ROWS))
174 await startViewer($, herdr, column.tools, 'tools')
175 }
176 if (!hasFlow) {
177 // herdr splits only right or down: make it below the shell, then swap it above.
178 const flow = await split($, herdr, column.shell, 'down', ratioKeeping(await heightIn($, herdr, column.shell), FLOW_ROWS))
179 await herdrRun($, herdr, ['pane', 'swap', '--source-pane', column.shell, '--target-pane', flow])
180 await startViewer($, herdr, flow, 'flow')
181 column.flow = flow
182 }
183 } else {
184 if (known !== undefined) {
185 await Promise.all([hasFlow && close($, herdr, known.flow), hasTools && close($, herdr, known.tools)])
186 }
187 column = await build($, herdr)
188 }
189
190 await $.store.set(columnKey(herdr), column)
191
192 return column
193}
194
195// Writes the feed the column's viewers read, once more for any change made meanwhile.
196function feed($: EngineInterface, live: Live) {
197 const herdr = live.herdr
198 if (herdr === undefined) return
199
200 live.isDirty = true
201 live.writing ??= (async () => {
202 while (live.isDirty) {
203 live.isDirty = false
204 const [callList, flowList, session] = await Promise.all([read($, calls), read($, flows), $.session.id()])
205 // `session` tells the shell pane's Agents views whose agents to show; it changes on a /clear
206 await $.fs.write(herdr.feed, JSON.stringify({ calls: callList, flows: flowList, session }))
207 }
208 })()
209 .catch(() => undefined)
210 .finally(() => {
211 live.writing = undefined
212 })
213}
214
215async function addFlows($: EngineInterface, live: Live, drafts: readonly Draft[]) {
216 if (drafts.length === 0) return
217
218 const at = await $.clock.now()
219 const made = drafts.map((draft): Flow => ({ ...draft, id: `${at}-${live.count++}`, at }))
220 await update($, flows, list => [...list, ...made].slice(-KEEP))
221 feed($, live)
222}
223
224async function remoteHost($: EngineInterface, remote: string) {
225 try {
226 const ran = await $.process.run(['git', 'remote', 'get-url', remote], { timeoutMs: 2_000 })
227
228 return ran.exitCode === 0 ? hostOfRemote(ran.stdout.trim()) : undefined
229 } catch {
230 return undefined
231 }
232}
233
234async function recordTool($: EngineInterface, live: Live, tool: string, input: Record<string, unknown>, ran: { text?: string; result?: unknown; isError?: boolean }) {
235 const { drafts, remotes } = flowsOfTool(tool, input, ran)
236 const fromRemotes = await Promise.all(
237 remotes.map(async ({ remote, what, detail, isFailed }): Promise<Draft> => {
238 const host = await remoteHost($, remote)
239
240 return { from: 'Bash', to: host ?? `git remote ${remote}`, what, detail, isFailed }
241 }),
242 )
243
244 await addFlows($, live, [...drafts, ...fromRemotes])
245}
246
247// Runs recording work apart from the hook that saw it, so it can never fail the hook.
248const aside = (work: () => Promise<unknown>) => {
249 void Promise.resolve()
250 .then(work)
251 .catch(() => undefined)
252}
253
254// The column beside Claude in herdr; elsewhere, or when herdr refuses, the pane
255// inside Claude. Unasked, a Claude pane too narrow to share keeps the pane inside.
256async function openHere($: EngineInterface, live: Live, isAsked: boolean) {
257 const herdr = live.herdr
258 if (herdr === undefined) {
259 await $.ui.open({ id: PANE, title: TITLE })
260
261 return { text: 'Activity pane opened.' }
262 }
263
264 try {
265 const hasColumn = (await $.store.get(columnKey(herdr))) !== undefined
266 if (!isAsked && !hasColumn && (await sizeOf($, herdr, herdr.pane)).width < MIN_COLUMNS) {
267 await $.ui.open({ id: PANE, title: TITLE })
268
269 return { text: 'Activity pane opened; /activity opens the column beside Claude.' }
270 }
271
272 await openColumn($, herdr)
273 feed($, live)
274
275 return { text: 'Activity column open beside Claude.' }
276 } catch (error) {
277 await $.ui.open({ id: PANE, title: TITLE })
278 const why = error instanceof Error ? error.message : String(error)
279
280 return { text: `The herdr column could not be opened (${why}); the Activity pane is open instead.`, isFallback: true }
281 }
282}
283
284export const register: Register = on => {
285 const live: Live = { api: 'api.anthropic.com', isDirty: false, count: 0 }
286
287 on('session.start', async ($, e, next) => {
288 await $.command.register({
289 name: 'activity',
290 description: 'Open the activity column beside Claude in herdr, or the Activity pane elsewhere',
291 })
292 live.api = await apiOf($)
293 live.herdr = e.isInteractive ? await herdrOf($, await $.session.root()) : undefined
294
295 const opened = await openHere($, live, false)
296 if (opened.isFallback === true) $.ui.toast(opened.text)
297
298 return next(e)
299 })
300
301 on('command.run', { command: 'activity' }, async $ => ({ text: (await openHere($, live, true)).text }))
302
303 on('session.end', async ($, e, next) => {
304 const herdr = live.herdr
305 if (herdr !== undefined && EXITS.has(e.reason)) {
306 const known = (await $.store.get(columnKey(herdr))) as Column | undefined
307 if (known !== undefined) {
308 await Promise.all([known.flow, known.shell, known.tools].map(pane => close($, herdr, pane)))
309 await $.store.delete(columnKey(herdr))
310 }
311 }
312
313 return next(e)
314 })
315
316 on('tool.call', async ($, e, next) => {
317 const startedAt = await $.clock.now()
318 const call: ActivityCall = {
319 id: e.tool_use_id ?? `${String(e.tool)}-${startedAt}`,
320 tool: String(e.tool),
321 detail: detailOf(e),
322 isSubagent: e.agentId !== undefined,
323 state: 'running',
324 }
325 await update($, calls, list => [...list, call].slice(-KEEP))
326 feed($, live)
327
328 let state: CallState = 'failed'
329 try {
330 const ran = await next(e)
331 state = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'failed' : 'done'
332 if (ran.deny === undefined) {
333 aside(() => recordTool($, live, String(e.tool), inputOf(e), { text: ran.text, result: ran.result, isError: ran.isError }))
334 }
335
336 return ran
337 } finally {
338 const ms = (await $.clock.now()) - startedAt
339 await update($, calls, list =>
340 list.map(one => (one.id === call.id ? { ...one, state, ms } : one)),
341 )
342 feed($, live)
343 }
344 })
345
346 // Each model request: forwarded untouched, recorded once it has settled.
347 on('turn.step', async function* ($, e, next) {
348 const result = yield* next(e)
349 aside(() => addFlows($, live, flowsOfStep(e, result.usage, result.serverToolUses ?? [], live.api)))
350
351 return result
352 })
353
354 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
355 const { Box, Text } = $.ui.resolve(e)
356 const list = await read($, calls)
357 const room = e.props.placement === 'dock' ? Math.max(1, e.props.scroll.bodyRows - 1) : 8
358 const failed = list.filter(call => call.state === 'failed').length
359
360 return (
361 <Box flexDirection="column">
362 <Text color={MOCHA.overlay1}>
363 {list.length === 0 ? 'No tool calls yet.' : `${list.length} tool calls`}
364 {failed > 0 && <Text color={MOCHA.red}>, {failed} failed</Text>}
365 </Text>
366 {list.slice(-room).map(call => (
367 <Box key={call.id}>
368 <Text wrap="truncate-end">
369 <Text color={COLOR[call.state]}>{MARK[call.state]}</Text>{' '}
370 {call.isSubagent && <Text color={MOCHA.mauve}>↳ </Text>}
371 <Text color={MOCHA.blue} weight="bold">{call.tool}</Text>
372 {call.ms !== undefined && <Text color={MOCHA.overlay0}> {took(call.ms)}</Text>}
373 <Text color={MOCHA.subtext0}> {call.detail}</Text>
374 </Text>
375 </Box>
376 ))}
377 </Box>
378 )
379 })
380}
381hooks/flows.ts 217 lines1import type { Amount, Flow } from '../types'
2
3// A flow before it has a time and an id.
4export type Draft = Omit<Flow, 'id' | 'at'>
5
6// Where a shell command points: a host, or a git remote still to be looked up.
7export type Target = { host: string; what: string } | { remote: string; what: string }
8
9const LOCAL = [/^localhost$/i, /^127\./, /^0\.0\.0\.0$/, /^::1$/]
10const LAN = [/^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^169\.254\./, /\.local$/i]
11
12export const tagOf = (host: string): Flow['tag'] =>
13 LOCAL.some(r => r.test(host)) ? 'local' : LAN.some(r => r.test(host)) ? 'LAN' : undefined
14
15const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
16
17const sizeOf = (value: unknown) => {
18 try {
19 return typeof value === 'string' ? value.length : (JSON.stringify(value)?.length ?? 0)
20 } catch {
21 return 0
22 }
23}
24
25const chars = (n: number): Amount => ({ n, unit: 'B' })
26
27// The fields of a tool call that are the engine's, not the tool's input.
28const META = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'agent', 'requestMeta'])
29
30export const inputOf = (e: object) =>
31 Object.fromEntries(Object.entries(e).filter(([key]) => !META.has(key)))
32
33// "mcp__claude_ai_Gmail__search_threads": the server, how to name it, the tool.
34export const mcpOf = (tool: string) => {
35 const parts = /^mcp__(.+?)__(.+)$/.exec(tool)
36 if (parts === null) return undefined
37
38 const [, server = '', name = ''] = parts
39 if (server.startsWith('claude_ai_')) {
40 return { to: `${server.slice('claude_ai_'.length).replace(/_/g, ' ')} via claude.ai`, tool: name }
41 }
42 if (server.startsWith('plugin_')) {
43 const rest = server.slice('plugin_'.length)
44 const cut = rest.indexOf('_')
45
46 return { to: cut === -1 ? rest : rest.slice(cut + 1), tool: name }
47 }
48
49 return { to: server, tool: name }
50}
51
52const URL_HOST = /\b(?:https?|wss?|ftp|mqtts?|coap|rtsp|tcp|udp):\/\/(?:[^\s/@'"]+@)?(\[[^\]]+\]|[^\s/:'"?#]+)(?::(\d+))?/gi
53const SCP_HOST = /\b[\w.-]+@([\w.-]+):/g
54const IPV4 = /(?<![\d.])((?:\d{1,3}\.){3}\d{1,3})(?::(\d+))?(?![\d.])/g
55const APP = /tell application "([^"]+)"/g
56const NET = new Set([
57 'ssh', 'sftp', 'mosh', 'telnet', 'nc', 'ncat', 'netcat', 'socat', 'ping', 'ping6', 'traceroute',
58 'mtr', 'nmap', 'dig', 'nslookup', 'host', 'ftp', 'curl', 'wget', 'http', 'https', 'xh', 'scp', 'rsync',
59])
60const parsedUrl = (text: string) => {
61 try {
62 return new URL(text)
63 } catch {
64 return undefined
65 }
66}
67
68// Destination lines one Bash command makes before the "+N more" line.
69const SHOWN = 5
70
71const GIT_REMOTE = new Set(['push', 'pull', 'fetch', 'clone', 'ls-remote'])
72const PREFIX = new Set(['sudo', 'time', 'exec', 'command', 'nohup', 'env'])
73
74const isIpv4 = (text: string) => text.split('.').every(octet => Number(octet) <= 255)
75
76const unquote = (token: string) => token.replace(/^['"]|['"]$/g, '')
77
78// Each host a shell command reaches, once, with the program that reaches it.
79export const targetsOf = (command: string): Target[] => {
80 const found = new Map<string, Target>()
81 const add = (target: Target) => {
82 const key = 'host' in target ? target.host : `remote:${target.remote}`
83 if (!found.has(key)) found.set(key, target)
84 }
85
86 for (const [, app = ''] of command.matchAll(APP)) add({ host: app, what: 'Apple Events' })
87
88 for (const segment of command.split(/\s*(?:&&|\|\||[;|\n])\s*/)) {
89 const tokens = segment.trim().split(/\s+/).map(unquote)
90 while (tokens.length > 0 && (PREFIX.has(tokens[0] ?? '') || /^\w+=/.test(tokens[0] ?? ''))) tokens.shift()
91
92 const program = (tokens[0] ?? '').split('/').pop() ?? ''
93 const sub = tokens[1] ?? ''
94 const what = program === 'git' ? `git ${sub}` : program
95 const before = found.size
96
97 for (const [, host = ''] of segment.matchAll(URL_HOST)) add({ host: host.replace(/^\[|\]$/g, ''), what })
98 if (NET.has(program) || program === 'git') {
99 for (const [, host = ''] of segment.matchAll(SCP_HOST)) add({ host, what })
100 }
101 for (const [, ip = ''] of segment.matchAll(IPV4)) if (isIpv4(ip)) add({ host: ip, what })
102
103 if (program === 'git' && GIT_REMOTE.has(sub) && found.size === before) {
104 const remote = tokens.slice(2).find(token => !token.startsWith('-'))
105 if (sub !== 'clone') add({ remote: remote ?? 'origin', what })
106 } else if (NET.has(program) && found.size === before) {
107 const host = tokens
108 .slice(1)
109 .map(token => token.replace(/^[^@\s]+@/, ''))
110 .find(token => !token.startsWith('-') && /^[\w.-]+$/.test(token) && !/^\d+$/.test(token))
111 if (host !== undefined) add({ host, what })
112 }
113 }
114
115 return [...found.values()]
116}
117
118// A host from a git remote's URL: "git@github.com:me/x.git", "https://github.com/me/x".
119export const hostOfRemote = (url: string) =>
120 /^[\w.-]+@([\w.-]+):/.exec(url)?.[1] ?? /^\w+:\/\/(?:[^@/]+@)?([^/:]+)/.exec(url)?.[1]
121
122type Ran = { text?: string; result?: unknown; isError?: boolean }
123
124// The transfers a finished tool call made, `remote` targets left for the caller.
125export const flowsOfTool = (tool: string, input: Record<string, unknown>, ran: Ran) => {
126 const received = chars(ran.text?.length ?? sizeOf(ran.result))
127 const isFailed = ran.isError === true ? true : undefined
128 const mcp = mcpOf(tool)
129
130 if (mcp !== undefined) {
131 const args = JSON.stringify(input)
132 const draft: Draft = { from: 'MCP', to: mcp.to, what: mcp.tool, up: chars(args.length), down: received, detail: oneLine(args), isFailed }
133
134 return { drafts: [draft], remotes: [] }
135 }
136
137 if (tool === 'WebFetch' && typeof input.url === 'string') {
138 // URL's parser, not a regex: in http://127.0.0.1@evil.com the host is evil.com, not a local address
139 const url = parsedUrl(input.url)
140 const host = url?.hostname.replace(/^\[|\]$/g, '') ?? input.url
141 const draft: Draft = { from: 'WebFetch', to: host, tag: tagOf(host), what: `GET ${url ? url.pathname + url.search : '/'}`, down: received, detail: input.url, isFailed }
142
143 return { drafts: [draft], remotes: [] }
144 }
145
146 if (tool === 'WebSearch' && typeof input.query === 'string') {
147 const draft: Draft = { from: 'WebSearch', to: 'web search (run by Anthropic)', what: oneLine(input.query), down: received, detail: input.query, isFailed }
148
149 return { drafts: [draft], remotes: [] }
150 }
151
152 if (tool === 'Bash' && typeof input.command === 'string') {
153 const detail = oneLine(input.command)
154 // five lines at most, and one saying how many more, so the list never looks complete when it is not
155 const all = targetsOf(input.command)
156 const targets = all.slice(0, SHOWN)
157 const drafts: Draft[] = []
158 const remotes: { remote: string; what: string; detail: string; isFailed?: true }[] = []
159
160 for (const target of targets) {
161 if ('remote' in target) remotes.push({ ...target, detail, isFailed })
162 else {
163 const tag = target.what === 'Apple Events' ? 'local' : tagOf(target.host)
164 drafts.push({ from: 'Bash', to: target.host, tag, what: target.what, detail, isFailed })
165 }
166 }
167
168 if (all.length > SHOWN) {
169 drafts.push({ from: 'Bash', to: `+${all.length - SHOWN} more destinations`, what: 'not listed', detail, isFailed })
170 }
171
172 return { drafts, remotes }
173 }
174
175 return { drafts: [], remotes: [] }
176}
177
178type Usage = {
179 input_tokens: number
180 output_tokens: number
181 cache_read_input_tokens: number
182 cache_creation_input_tokens: number
183}
184
185type Step = { model: string; messageCount: number; agentId?: string }
186type ServerUse = { name: string; input: unknown }
187
188// One model request: the conversation up, the answer down, and the tools the
189// API ran on its side while answering.
190export const flowsOfStep = (step: Step, usage: Usage | null, serverUses: readonly ServerUse[], api: string) => {
191 const from = step.agentId === undefined ? 'Claude' : 'Subagent'
192 const request: Draft = {
193 from,
194 to: api,
195 what: step.model,
196 ...(usage === null
197 ? {}
198 : {
199 up: { n: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, unit: 'tok' as const },
200 down: { n: usage.output_tokens, unit: 'tok' as const },
201 }),
202 detail: `sent ${step.messageCount} messages: the conversation so far, with the files and tool output in it`,
203 }
204 const served = serverUses.map((use): Draft => {
205 const query = (use.input as { query?: unknown } | null)?.query
206
207 return {
208 from,
209 to: `${use.name.replace(/_/g, ' ')} (run by Anthropic)`,
210 what: typeof query === 'string' ? oneLine(query) : use.name,
211 detail: oneLine(sizeOf(use.input) > 0 ? JSON.stringify(use.input) : use.name),
212 }
213 })
214
215 return [request, ...served]
216}
217hooks/herdr.ts 60 lines1// The three panes of the column, by herdr pane id.
2export type Column = { flow: string; shell: string; tools: string }
3
4// Rows each small pane shows, its header included; herdr spends 2 more on its frame.
5export const FLOW_ROWS = 9
6export const TOOLS_ROWS = 5
7const FRAME = 2
8
9const clamp = (ratio: number) => Math.min(0.9, Math.max(0.1, ratio))
10
11// herdr's `--ratio` is the share the split pane keeps, the new pane the rest.
12export const ratiosOf = (height: number) => {
13 const flow = FLOW_ROWS + FRAME
14 const tools = TOOLS_ROWS + FRAME
15
16 return { flow: clamp(flow / height), tools: clamp((height - flow - tools) / (height - flow)) }
17}
18
19// The ratio that leaves a new pane of `rows` below a pane `height` tall.
20export const ratioLeaving = (height: number, rows: number) => clamp((height - rows - FRAME) / height)
21
22// The ratio that keeps `rows` in the split pane itself.
23export const ratioKeeping = (height: number, rows: number) => clamp((rows + FRAME) / height)
24
25export const ratioText = (ratio: number) => ratio.toFixed(4)
26
27// The new pane's id from `herdr pane split`'s answer.
28export const paneIdOf = (stdout: string) => {
29 const id = (JSON.parse(stdout) as { result?: { pane?: { pane_id?: unknown } } }).result?.pane?.pane_id
30 if (typeof id !== 'string') throw new Error('herdr named no new pane')
31
32 return id
33}
34
35// The narrowest Claude pane the column opens beside unasked, as Claude Code
36// seats a pane it opens unasked; /activity opens it beside any.
37export const MIN_COLUMNS = 144
38
39// A pane's size from `herdr pane layout`'s answer.
40export const rectOf = (stdout: string, pane: string) => {
41 type Layout = { result?: { layout?: { panes?: { pane_id: string; rect: { width: number; height: number } }[] } } }
42 const panes = (JSON.parse(stdout) as Layout).result?.layout?.panes ?? []
43
44 return panes.find(one => one.pane_id === pane)?.rect
45}
46
47const quoted = (text: string) => `'${text.replace(/'/g, `'\\''`)}'`
48
49// What a viewer pane's shell is told to run; `exec`, so quitting it closes the pane.
50export const viewerCommand = (root: string, viewer: 'flow' | 'tools', feed: string) =>
51 `exec node ${quoted(`${root}/viewer/${viewer}.mjs`)} ${quoted(feed)}`
52
53// The shell pane's command: a shell wrapped so Shift+Tab shows Agents and Agent graph.
54export const shellCommand = (root: string, feed: string) =>
55 `exec python3 ${quoted(`${root}/viewer/shell_tabs.py`)} ${quoted(feed)}`
56
57// The feed's file for the column beside one herdr pane.
58export const feedPathOf = (tmp: string, pane: string) =>
59 `${tmp.replace(/\/+$/, '')}/claude-activity/${pane.replace(/[^\w-]/g, '-')}.json`
60types/index.d.ts 35 lines1export type CallState = 'running' | 'done' | 'failed' | 'denied'
2
3export type ActivityCall = {
4 id: string
5 tool: string
6 detail: string
7 isSubagent: boolean
8 state: CallState
9 ms?: number
10}
11
12// How much went one way: tokens for a model request, characters for the rest.
13export type Amount = { n: number; unit: 'tok' | 'B' }
14
15// One transfer in the data flow log: who sent what where.
16export type Flow = {
17 id: string
18 at: number
19 from: string
20 to: string
21 // A destination on the local network, or on this machine.
22 tag?: 'LAN' | 'local'
23 what: string
24 up?: Amount
25 down?: Amount
26 detail: string
27 isFailed?: boolean
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'activity-pane': { calls: ActivityCall[]; flows: Flow[] }
33 }
34}
35