SLOPSHOPPER

activity-pane

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

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-24Pumpernickel01/claude-mods/activity-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · activity-pane
│ ┃ Activity ✕ › fix the failing auth test and add an audit log call │ ┃ 9 tool calls, 1 failed │ ┃ ✓ Read 20ms auth.ts ⏺ Read(src/auth.ts) │ ┃ ✓ Grep 20ms refresh\( ⎿ Read 6 lines │ ┃ ✓ Edit 20ms auth.ts ⏺ Update(src/auth.ts) │ ┃ ✓ Write 20ms audit.ts ⎿ Added 2 lines, removed 1 line │ ┃ ✓ Write 20ms cache.ts ⏺ Bash(bun test) │ ┃ ✗ Bash 20ms bun test ⎿ 3 pass, 1 fail │ ┃ ✓ Bash 20ms git status --porcelain │ ┃ ✓ Bash 20ms rm -rf build && git push --forc… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Bash 20ms cat .env │ ✻ Worked for 42s · done 4:20 PM │ │ › /activity │ ⎿ activity-pane: Activity pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Activity
9 tool calls, 1 failed ✓ Read 20ms auth.ts ✓ Grep 20ms refresh\( ✓ Edit 20ms auth.ts ✓ Write 20ms audit.ts ✓ Write 20ms cache.ts ✗ Bash 20ms bun test ✓ Bash 20ms git status --porcelain ✓ Bash 20ms rm -rf build && git push --force origin main ✓ Bash 20ms cat .env
README

activity-pane

A column beside Claude when Claude Code runs inside herdr: three panes to the right of Claude's.

  • Data flow (top): one line per transfer Claude makes (model requests, web fetches, MCP calls, hosts shell commands reach). Tab switches to Guide, ~/.claude/workflow-guide.md.
  • Shell (middle, the biggest): your shell. Shift+Tab opens Agents, a table of this session's agents; Tab cycles Agents → Agent graph → Shell, Shift+Tab goes back one, Esc or q returns to the shell. The agent data comes from ~/.claude/agent-monitor/events.jsonl, written by the mods plugin's monitor hooks (../monitor). Shift+Tab here replaces zsh's reverse menu-complete.
  • Tools (bottom): Claude's tool calls with outcome and duration.

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.

Install

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.

Tests

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).

Source 4 files
hooks/register.tsx 381 lines
1import { 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}
381
hooks/flows.ts 217 lines
1import 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}
217
hooks/herdr.ts 60 lines
1// 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`
60
types/index.d.ts 35 lines
1export 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