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…

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).
| Token | Example | When |
|---|---|---|
tool | Edit search/rank.ts, Bash bun test, Grep retry | the call at work; cleared when the turn ends |
turnStart | 1792400000000 (epoch ms) | when the turn began; cleared when it ends |
todo | 3/7 | the model's to-do list (TodoWrite or the Task tools); a finished list goes when the turn ends |
todoNow | Writing the tests | the item in progress |
context | 42 | context window used, percent |
cost | 1.23 | the session's cost in USD, where Claude Code keeps one |
branch | main | git branch --show-current, read at start and at each turn |
limit5h · limit5hReset | 58 · 2026-10-14T15:20:00Z | the 5-hour usage window and when it resets |
limitWeek · limitWeekReset | 38 · 2026-10-21T02:00:00Z | the 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.
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.
claude plugin uninstall arcdr-report@arcdr
claude plugin validate .
claude plugin test .hooks/register.ts 342 lines1import 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}
342types/index.d.ts 8 lines1// 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