SLOPSHOPPER

arcdr-report

Reports what each Claude Code session is doing to herdr — the tool at work, the to-do list, context, cost, branch and usage limits — so arcdr can show it on a…

newguardprocesstimer
★ 3v0.1.0MITupdated 2026-10-15arcnessai/arcdr/mods/arcdr-report
A shopper browsing a rack in a slop shop
README

arcdr-report

A Claude Code mod that tells herdr what each session is doing, so arcdr can show it on your phone. It draws nothing in the terminal and writes nothing but herdr's pane metadata (herdr pane report-metadata --source arcdr-report).

What it reports

TokenExampleWhen
toolEdit search/rank.ts, Bash bun test, Grep retrythe call at work; cleared when the turn ends
turnStart1792400000000 (epoch ms)when the turn began; cleared when it ends
todo3/7the model's to-do list (TodoWrite or the Task tools); a finished list goes when the turn ends
todoNowWriting the teststhe item in progress
context42context window used, percent
cost1.23the session's cost in USD, where Claude Code keeps one
branchmaingit branch --show-current, read at start and at each turn
limit5h · limit5hReset58 · 2026-10-14T15:20:00Zthe 5-hour usage window and when it resets
limitWeek · limitWeekReset38 · 2026-10-21T02:00:00Zthe weekly window

Only the main conversation counts — a subagent's calls are its own work. A burst of calls is one report (at most one every half second), carrying only what changed. Nothing is sent outside a herdr pane (HERDR_PANE_ID unset) or in an unattended run (claude -p), and leaving a session clears what it reported.

tool shows a command's first line as typed, the way the terminal does: whatever you would not want on your phone's screen, keep out of command lines.

Install

From a clone of arcdr:

claude plugin marketplace add /path/to/arcdr
claude plugin install arcdr-report@arcdr

New sessions load it; running ones after /reload-plugins. To update, git pull the clone and run /reload-plugins — the plugin is read from the folder itself.

Uninstall

claude plugin uninstall arcdr-report@arcdr

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.ts 342 lines
1import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit, Timer } from 'claude-code'
2
3import type { TodoItem, TodoStatus, Tokens } from '../types'
4
5// Reports what this session is doing to herdr as pane tokens, so a screen that is not this terminal —
6// arcdr on a phone — can show it. Nothing is drawn here and nothing is written but herdr's metadata.
7
8const SOURCE = 'arcdr-report'
9// A burst of tool calls becomes one report, the last value winning.
10const DEBOUNCE_MS = 500
11const MAX_TEXT = 80
12
13// Tokens about a moment rather than the session: cleared when it passes, and at start, in case an earlier
14// run in this pane left them behind.
15const PASSING = ['tool', 'turnStart', 'todo', 'todoNow'] as const
16
17// Bookkeeping the model does for itself: what the session is doing is still the call before it.
18const QUIET = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'ToolSearch'])
19
20const STATUSES = new Set<string>(['pending', 'in_progress', 'completed'])
21
22/** One line, at most `max` characters. */
23export const clip = (text: string, max = MAX_TEXT): string => {
24  const line = text.replace(/\s+/g, ' ').trim()
25  return line.length <= max ? line : `${line.slice(0, max - 1)}…`
26}
27
28/** A path under the session's folder reads relative to it; any other stays whole. */
29export const relative = (path: string, cwd: string): string => {
30  if (path === cwd) return '.'
31  const base = cwd.endsWith('/') ? cwd : `${cwd}/`
32  return path.startsWith(base) ? path.slice(base.length) : path
33}
34
35const text = (value: unknown): string | undefined =>
36  typeof value === 'string' && value.trim() !== '' ? value : undefined
37
38const hostOf = (url: string | undefined): string | undefined => {
39  if (!url) return undefined
40  try {
41    return new URL(url).host || undefined
42  } catch {
43    return undefined
44  }
45}
46
47/**
48 * What a call is doing, short: "Edit search/rank.ts", "Bash bun test", "Grep retry". Undefined for the
49 * model's own bookkeeping, which leaves the line as it was.
50 */
51export const toolLine = (tool: string, input: Record<string, unknown>, cwd: string): string | undefined => {
52  if (QUIET.has(tool)) return undefined
53  const path = text(input.file_path) ?? text(input.notebook_path)
54  if (path) return clip(`${tool} ${relative(path, cwd)}`)
55  if (tool === 'Bash') {
56    const command = text(input.command)
57    return clip(command ? `Bash ${command.trim().split('\n')[0]}` : 'Bash')
58  }
59  const name = tool.startsWith('mcp__') ? tool.slice('mcp__'.length).split('__').join(' ') : tool
60  const detail =
61    text(input.pattern) ?? text(input.query) ?? text(input.skill) ?? text(input.description) ?? hostOf(text(input.url))
62  return clip(detail ? `${name} ${detail}` : name)
63}
64
65const statusOf = (value: unknown): TodoStatus | undefined =>
66  typeof value === 'string' && STATUSES.has(value) ? (value as TodoStatus) : undefined
67
68const record = (value: unknown): Record<string, unknown> | undefined =>
69  value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
70
71/** The list as TodoWrite wrote it; undefined for anything that is not one. */
72export const fromTodoWrite = (todos: unknown): TodoItem[] | undefined => {
73  if (!Array.isArray(todos)) return undefined
74  return todos.flatMap(raw => {
75    const item = record(raw)
76    const status = statusOf(item?.status)
77    const content = text(item?.content)
78    return item && status && content ? [{ text: content, doing: text(item.activeForm), status }] : []
79  })
80}
81
82/**
83 * The Task tools' list after one of their calls answered, by task id; undefined when the call was not
84 * one of them. Ids come from the answers, since TaskCreate's input carries none.
85 */
86export const nextTasks = (
87  tasks: ReadonlyMap<string, TodoItem>,
88  tool: string,
89  input: Record<string, unknown>,
90  result: unknown,
91): Map<string, TodoItem> | undefined => {
92  const answer = record(result)
93  const next = new Map(tasks)
94  if (tool === 'TaskCreate') {
95    const id = text(record(answer?.task)?.id)
96    const subject = text(input.subject)
97    if (!id || !subject) return undefined
98    next.set(id, { text: subject, doing: text(input.activeForm), status: 'pending' })
99    return next
100  }
101  if (tool === 'TaskUpdate') {
102    const id = text(input.taskId)
103    if (!id || answer?.success === false) return undefined
104    if (input.status === 'deleted') {
105      next.delete(id)
106      return next
107    }
108    const known = next.get(id)
109    const subject = text(input.subject) ?? known?.text
110    if (!subject) return undefined
111    next.set(id, {
112      text: subject,
113      doing: text(input.activeForm) ?? known?.doing,
114      status: statusOf(input.status) ?? known?.status ?? 'pending',
115    })
116    return next
117  }
118  if (tool === 'TaskList') {
119    if (!Array.isArray(answer?.tasks)) return undefined
120    const listed = new Map<string, TodoItem>()
121    for (const raw of answer.tasks) {
122      const task = record(raw)
123      const id = text(task?.id)
124      const subject = text(task?.subject)
125      const status = statusOf(task?.status)
126      if (id && subject && status) listed.set(id, { text: subject, doing: tasks.get(id)?.doing, status })
127    }
128    return listed
129  }
130  if (tool === 'TaskGet') {
131    const task = record(answer?.task)
132    const id = text(task?.id)
133    const subject = text(task?.subject)
134    const status = statusOf(task?.status)
135    if (!id || !subject || !status) return undefined
136    next.set(id, { text: subject, doing: tasks.get(id)?.doing, status })
137    return next
138  }
139  return undefined
140}
141
142/**
143 * "3/7" and the item in progress. A list whose every item is done shows "7/7" while the turn still runs
144 * and goes once it ends: by then it says nothing the conversation does not.
145 */
146export const todoTokens = (items: readonly TodoItem[] | undefined, isTurnOver: boolean): Tokens => {
147  if (!items || items.length === 0) return { todo: undefined, todoNow: undefined }
148  const done = items.filter(item => item.status === 'completed').length
149  if (done === items.length && isTurnOver) return { todo: undefined, todoNow: undefined }
150  const now = items.find(item => item.status === 'in_progress')
151  return { todo: `${done}/${items.length}`, todoNow: now ? clip(now.doing ?? now.text) : undefined }
152}
153
154/** The context window's fill and the session's cost, each only when the host has a figure for it. */
155export const measureTokens = (context: SessionContextUsage | undefined, cost: SessionCost | undefined): Tokens => ({
156  ...(context?.percent === undefined ? {} : { context: String(Math.round(context.percent)) }),
157  ...(cost === undefined ? {} : { cost: cost.usd.toFixed(2) }),
158})
159
160/** Each usage window's percent used and when it resets (an ISO time; the reader counts down). */
161export const limitTokens = (windows: readonly SessionRateLimit[]): Tokens => {
162  const five = windows.find(w => w.kind === 'five_hour')
163  const week = windows.find(w => w.kind === 'seven_day')
164  return {
165    limit5h: five ? String(Math.round(five.percentUsed)) : undefined,
166    limit5hReset: five?.resetsAt ?? undefined,
167    limitWeek: week ? String(Math.round(week.percentUsed)) : undefined,
168    limitWeekReset: week?.resetsAt ?? undefined,
169  }
170}
171
172/** The flags of one report that brings herdr from `sent` to `wanted`; empty when nothing changed. */
173export const reportArgs = (wanted: Tokens, sent: Tokens): string[] => {
174  const names = [...new Set([...Object.keys(wanted), ...Object.keys(sent)])]
175  return names
176    .filter(name => wanted[name] !== sent[name])
177    .flatMap(name => {
178      const value = wanted[name]
179      return value === undefined ? ['--clear-token', name] : ['--token', `${name}=${value}`]
180    })
181}
182
183// The module's own variables start over on a reload, and session.start runs again then: nothing here
184// needs to outlive it.
185let pane: string | undefined
186let herdr = 'herdr'
187// An unattended `claude -p` job started from a herdr tab carries that tab's HERDR_PANE_ID: it must not
188// write over the person's pane.
189let isReporting = false
190let cwd = '/'
191let wanted: Tokens = {}
192let sent: Tokens = {}
193let list: TodoItem[] | undefined
194let tasks = new Map<string, TodoItem>()
195let isTurnOver = true
196let timer: Timer | null = null
197let isFlushing = false
198
199const set = ($: EngineInterface, tokens: Tokens) => {
200  wanted = { ...wanted, ...tokens }
201  schedule($)
202}
203
204const schedule = ($: EngineInterface) => {
205  if (!isReporting || timer) return
206  timer = $.clock.after(DEBOUNCE_MS, () => {
207    timer = null
208    void flush($)
209  })
210}
211
212// One process for every change since the last report, carrying only the tokens that changed.
213const flush = async ($: EngineInterface) => {
214  if (!isReporting || !pane) return
215  if (isFlushing) return schedule($)
216  const args = reportArgs(wanted, sent)
217  if (args.length === 0) return
218  const sending = { ...wanted }
219  isFlushing = true
220  try {
221    const ran = await $.process.run([herdr, 'pane', 'report-metadata', pane, '--source', SOURCE, ...args], {
222      timeoutMs: 5000,
223    })
224    // A refused report is sent again with the next change.
225    if (ran.exitCode === 0) sent = sending
226  } catch {
227    // herdr is gone or slow: the same.
228  } finally {
229    isFlushing = false
230  }
231}
232
233const branchOf = async ($: EngineInterface): Promise<string | undefined> => {
234  try {
235    const ran = await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 3000 })
236    return ran.exitCode === 0 ? clip(ran.stdout) || undefined : undefined
237  } catch {
238    return undefined
239  }
240}
241
242// What a new folder, branch or checkout says, read after the hook that asked has returned.
243const refresh = async ($: EngineInterface) => {
244  cwd = await $.session.cwd()
245  set($, { branch: await branchOf($) })
246}
247
248const begin = async ($: EngineInterface) => {
249  const usage = await $.session.usage()
250  set($, { ...measureTokens(usage.context, usage.cost), ...limitTokens(usage.rateLimits) })
251  await refresh($)
252}
253
254const noteList = ($: EngineInterface, tool: string, input: Record<string, unknown>, result: unknown) => {
255  if (tool === 'TodoWrite') {
256    list = fromTodoWrite(input.todos)
257  } else {
258    const next = nextTasks(tasks, tool, input, result)
259    if (!next) return
260    tasks = next
261    list = [...tasks.values()]
262  }
263  set($, todoTokens(list, isTurnOver))
264}
265
266export const register: Register = on => {
267  on('session.start', async ($, e, next) => {
268    const result = await next(e)
269    pane = await $.env.get('HERDR_PANE_ID')
270    isReporting = e.isInteractive && pane !== undefined && pane !== ''
271    if (!isReporting) return result
272    herdr = (await $.env.get('HERDR_BIN_PATH')) || 'herdr'
273    cwd = result.cwd
274    wanted = {}
275    sent = Object.fromEntries(PASSING.map(name => [name, '']))
276    list = undefined
277    tasks = new Map()
278    isTurnOver = true
279    // Read after the start, so the first prompt never waits on git or herdr.
280    $.clock.after(0, () => void begin($))
281    return result
282  })
283
284  on('turn.start', async ($, e, next) => {
285    if (isReporting && e.agentId === undefined) {
286      isTurnOver = false
287      set($, { turnStart: String(await $.clock.now()), ...todoTokens(list, false) })
288      $.clock.after(0, () => void refresh($))
289    }
290    return next(e)
291  })
292
293  // Every tool, the main loop's alone: a subagent's calls are its own work, under its own Agent line.
294  on('tool.call', async ($, e, next) => {
295    if (!isReporting || e.agentId !== undefined) return next(e)
296    const tool = String(e.tool)
297    const input = e as unknown as Record<string, unknown>
298    const line = toolLine(tool, input, cwd)
299    if (line !== undefined) set($, { tool: line })
300    const ran = await next(e)
301    if (ran.deny === undefined && ran.isError !== true) noteList($, tool, input, ran.result)
302    return ran
303  }).catch(($, e, next) => next(e))
304
305  on('turn.complete', async ($, e, next) => {
306    const result = await next(e)
307    if (isReporting && e.agentId === undefined) {
308      isTurnOver = true
309      set($, { tool: undefined, turnStart: undefined, ...todoTokens(list, true) })
310    }
311    return result
312  })
313
314  on('session.measure', async ($, e, next) => {
315    // A measurement before the first response carries no windows: the last reading stands.
316    if (isReporting) {
317      set($, { ...measureTokens(e.context, e.cost), ...(e.rateLimits.length > 0 ? limitTokens(e.rateLimits) : {}) })
318    }
319    return next(e)
320  })
321
322  on('session.end', async ($, e, next) => {
323    if (isReporting) {
324      timer?.cancel()
325      timer = null
326      if (e.reason === 'clear') {
327        // The session goes on with a new conversation: its list and its moment go, its measures stay.
328        list = undefined
329        tasks = new Map()
330        wanted = { ...wanted, ...Object.fromEntries(PASSING.map(name => [name, undefined])) }
331        await flush($)
332      } else {
333        // The session is going: nothing of it is left on the pane.
334        wanted = {}
335        await flush($)
336        isReporting = false
337      }
338    }
339    return next(e)
340  })
341}
342
types/index.d.ts 8 lines
1// Values reported to herdr as pane tokens, by name; undefined clears one. arcdr reads each as `token:<name>`.
2export type Tokens = Record<string, string | undefined>
3
4export type TodoStatus = 'pending' | 'in_progress' | 'completed'
5
6// One item of the list the model keeps, whether it wrote it with TodoWrite or the Task tools.
7export type TodoItem = { text: string; doing?: string; status: TodoStatus }
8