Records what Claude Code spends, turn by turn, and shows where it went: on which ticket, and how much of it is on tickets at all.

Records what Claude Code spends, turn by turn, and shows where it went without leaving the session. It runs in the terminal on macOS, Windows and Linux and in the Code tab of the Claude desktop app. In the VS Code panel, in CI and in cloud sessions it records token entries and shows nothing.
# inside Claude Code
/plugin marketplace add tokencontroller/tokencontroller
/plugin install token-controller
The mod keeps its token entries through the CLI, tc (install). Connect it to your organization with tc connect, or leave it local. Until tc is installed, the mod keeps the session's summary and tags on the machine and the band says how to install it.
tc, which reads the turn from Claude Code's own session files: tokens by kind, the model, the working directory, the branch, and ticket keys in your prompts. Only the keys leave a prompt, never its text.tc record, with the ticket and tags you set by hand.tc builds the draft token entry and its segments, and places it with your rules. The mod does not place anything itself (ADR 0010).$41.87 today · 91% on tickets · KD-214, and a note when your token sheet is late or the controller asks you to submit before the close./tc) shows the review list, where you fix a ticket and submit; a report by ticket, project, team or day; and your personal rules./tc ticket KD-214, /tc tag purpose=spike, /tc report [by] and /tc submit run at once, without a turn. Claude can call the mod's report tool to answer "what did KD-214 cost this week?".Everything the docs promise is on the Claude Code mod page.
| Path | What it holds |
|---|---|
.claude-plugin/plugin.json | The manifest. The marketplace that lists it is .claude-plugin/marketplace.json at the repo root. |
hooks/register.tsx | The hooks: recording, the band, the pane, /tc and the report tool. |
hooks/lib/ | The plain logic: the record for tc record, reading tc --json, formatting, the summary prompt. |
types/index.d.ts | The session values the band and the pane draw from. |
tests/ | Tests that run the hooks inside Claude Code's own engine against a tc answered from memory. |
pnpm lint && pnpm typecheck && pnpm test # what CI runs: hooks/lib and its *.spec.ts files
claude plugin validate . # what the engine will load, and what it refuses
claude plugin test . # tests/*.test.ts inside the engine
claude --plugin-dir . # a session with this folder loaded, reloaded on save
CI has no Claude Code, so it checks hooks/lib only. Run claude plugin validate and claude plugin test before every change to hooks/register.tsx. MIT license.
hooks/register.tsx 611 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Tab } from '../types'
5import {
6 bar,
7 headline,
8 money,
9 percent,
10 placedByLine,
11 placeLine,
12 reportTable,
13 reportText,
14 rowLabel,
15 sessionTicket,
16 sheetNote,
17 submittedText,
18 when,
19} from './lib/format.ts'
20import { HELP, parseCommand, sessionRecord } from './lib/records.ts'
21import { cleanSummary, SUMMARY_MODEL, SUMMARY_SYSTEM, summaryPrompt } from './lib/summary.ts'
22import {
23 parseJson,
24 readReport,
25 readReview,
26 readRules,
27 readSheet,
28 readSubmitted,
29 reason,
30} from './lib/tc.ts'
31
32type $ = EngineInterface
33
34const PANE = 'token-controller'
35const TOOL = 'mcp__token-controller__report'
36const INSTALL =
37 'Install tc to keep your token entries: brew tap tokencontroller/tap https://github.com/tokencontroller/tokencontroller && brew install tc'
38
39const sheet = atom({ plugin: 'token-controller', key: 'sheet' } as const, null)
40const review = atom({ plugin: 'token-controller', key: 'review' } as const, [])
41const report = atom({ plugin: 'token-controller', key: 'report' } as const, null)
42const reportBy = atom({ plugin: 'token-controller', key: 'reportBy' } as const, 'ticket')
43const rules = atom({ plugin: 'token-controller', key: 'rules' } as const, [])
44const tab = atom({ plugin: 'token-controller', key: 'tab' } as const, 'review')
45const editing = atom({ plugin: 'token-controller', key: 'editing' } as const, null)
46const notice = atom({ plugin: 'token-controller', key: 'notice' } as const, null)
47const problem = atom({ plugin: 'token-controller', key: 'problem' } as const, null)
48const ticket = atom({ plugin: 'token-controller', key: 'ticket' } as const, null)
49const handTags = atom({ plugin: 'token-controller', key: 'handTags' } as const, {})
50const summary = atom({ plugin: 'token-controller', key: 'summary' } as const, null)
51
52/** Where tc may be when the session's PATH does not have it (the desktop app). */
53const FALLBACK_PATHS = [
54 '/opt/homebrew/bin/tc',
55 '/usr/local/bin/tc',
56 '/home/linuxbrew/.linuxbrew/bin/tc',
57]
58
59type TcRun = { ok: boolean; missing: boolean; stdout: string; stderr: string }
60
61// The module's own values start over when it reloads; nothing here needs to last.
62let tcPath: string | undefined
63let queue: Promise<unknown> = Promise.resolve()
64let isSummarizing = false
65let isInteractive = true
66
67/** Runs one thing after another, so store writes never overlap. */
68function serial<T>(work: () => Promise<T>): Promise<T> {
69 const run = queue.then(work, work)
70 queue = run.catch(() => undefined)
71 return run
72}
73
74async function tc($: $, args: string[], stdin?: string): Promise<TcRun> {
75 const home = await $.env.get('HOME')
76 const paths = tcPath
77 ? [tcPath]
78 : ['tc', ...FALLBACK_PATHS, ...(home ? [`${home}/.local/bin/tc`, `${home}/.cargo/bin/tc`] : [])]
79 for (const path of paths) {
80 try {
81 const run = await $.process.run([path, ...args], {
82 env: { NO_COLOR: '1' },
83 timeoutMs: 20_000,
84 ...(stdin === undefined ? {} : { stdin }),
85 })
86 tcPath = path
87 return { ok: run.exitCode === 0, missing: false, stdout: run.stdout, stderr: run.stderr }
88 } catch (error) {
89 const text = error instanceof Error ? error.message : String(error)
90 if (!/ENOENT|not found|no such file|cannot (start|find)|spawn/i.test(text)) {
91 return { ok: false, missing: false, stdout: '', stderr: text }
92 }
93 }
94 }
95 return { ok: false, missing: true, stdout: '', stderr: '' }
96}
97
98async function setProblem($: $, run: TcRun | null): Promise<void> {
99 const text =
100 run === null ? null : run.missing ? INSTALL : reason(run.stderr, run.stdout, 'tc failed')
101 await update($, problem, () => text)
102}
103
104/**
105 * Keeps this session's record in the machine's store until tc has taken it.
106 * The record is the whole picture so far, so only the newest one waits.
107 */
108function remember($: $): Promise<void> {
109 return serial(async () => {
110 const [sessionId, line, tags] = await Promise.all([
111 $.session.id(),
112 read($, summary),
113 read($, handTags),
114 ])
115 await $.store.set(`record/${sessionId}`, sessionRecord(sessionId, line, tags))
116 })
117}
118
119/** Hands every waiting record, this session's and earlier ones', to tc. */
120function flush($: $): Promise<void> {
121 return serial(async () => {
122 const waiting = (await $.store.keys()).filter(key => key.startsWith('record/'))
123 for (const key of waiting) {
124 const record = await $.store.get(key)
125 if (record !== undefined) {
126 const run = await tc($, ['record', '--json'], `${JSON.stringify(record)}\n`)
127 if (!run.ok) {
128 await setProblem($, run)
129 return
130 }
131 }
132 await $.store.delete(key)
133 }
134 await setProblem($, null)
135 })
136}
137
138/** Reads the token sheet and the review list from tc for the band and the pane. */
139async function refresh($: $): Promise<void> {
140 const status = await tc($, ['status', '--json'])
141 if (!status.ok) {
142 await setProblem($, status)
143 return
144 }
145 await update($, sheet, () => readSheet(parseJson(status.stdout)))
146 const listed = await tc($, ['review', '--json'])
147 if (listed.ok) {
148 const rows = readReview(parseJson(listed.stdout))
149 await update($, review, () => rows)
150 const placed = sessionTicket(rows, await $.session.id())
151 await update($, ticket, () => placed)
152 }
153}
154
155async function refreshReport($: $): Promise<void> {
156 const by = await read($, reportBy)
157 const run = await tc($, ['report', '--by', by, '--json'])
158 if (run.ok) await update($, report, () => readReport(parseJson(run.stdout), by))
159 else await update($, notice, () => reason(run.stderr, run.stdout, 'tc report failed'))
160}
161
162async function refreshRules($: $): Promise<void> {
163 const run = await tc($, ['rules', '--json'])
164 if (run.ok) await update($, rules, () => readRules(parseJson(run.stdout)))
165}
166
167/**
168 * After a turn: tc reads the session's usage from Claude Code's own session
169 * files whenever it runs, so asking it for the band's figures is what brings
170 * the turn into the draft token entry.
171 */
172async function afterTurn($: $, isMain: boolean): Promise<void> {
173 if (isMain) await summarize($)
174 await flush($)
175 if (isInteractive) await refresh($)
176}
177
178/** Writes the one-line summary with a small model call of the mod's own. */
179async function summarize($: $): Promise<void> {
180 if (isSummarizing) return
181 isSummarizing = true
182 try {
183 const messages = (await $.session.messages()).map(m => ({ role: m.role, text: m.text }))
184 const previous = await read($, summary)
185 const answer = await $.model.complete({
186 model: SUMMARY_MODEL,
187 system: SUMMARY_SYSTEM,
188 prompt: summaryPrompt(previous, messages.slice(-12)),
189 maxTokens: 120,
190 })
191 const line = answer.isAnswered ? cleanSummary(answer.text) : null
192 if (line === null || line === previous) return
193 await update($, summary, () => line)
194 await remember($)
195 } finally {
196 isSummarizing = false
197 }
198}
199
200async function setTags($: $, tags: Record<string, string>): Promise<string> {
201 await update($, handTags, current => ({ ...current, ...tags }))
202 await remember($)
203 await flush($)
204 await refresh($)
205 const problemNow = await read($, problem)
206 const set = Object.entries(tags)
207 .map(([name, value]) => `${name} ${value}`)
208 .join(' · ')
209 return problemNow === null ? `This session: ${set}` : `This session: ${set}. ${problemNow}`
210}
211
212async function submit($: $): Promise<string> {
213 await flush($)
214 const run = await tc($, ['submit', '--json'])
215 await refresh($)
216 if (run.missing) return INSTALL
217 return run.ok
218 ? submittedText(readSubmitted(parseJson(run.stdout)))
219 : reason(run.stderr, run.stdout, 'tc submit failed')
220}
221
222function openPane($: $) {
223 return $.ui.open({ id: PANE, title: 'Token Controller' })
224}
225
226export const register: Register = on => {
227 on('session.start', async ($, e, next) => {
228 const started = await next(e)
229 isInteractive = e.isInteractive
230 await $.command.register({
231 name: 'tc',
232 description:
233 'Token Controller: set this session’s ticket, see reports, submit your token sheet',
234 argumentHint: '[ticket KEY | tag NAME=VALUE | report [BY] | submit]',
235 })
236 await $.tool.register({
237 name: 'report',
238 description:
239 'Token Controller report: what agent work cost, grouped by ticket, project, team, any other tag name, or day. ' +
240 'Use it when the person asks what something cost, such as "what did KD-214 cost this week?" ' +
241 '(by day, filter ticket=KD-214, then add up the days of this week), and answer with the table it returns.',
242 inputSchema: {
243 type: 'object',
244 properties: {
245 by: { type: 'string', description: 'ticket, project, team, any tag name, or day' },
246 filter: {
247 type: 'string',
248 description: 'Limit to one tag value, as name=value, such as ticket=KD-214',
249 },
250 },
251 required: ['by'],
252 },
253 isDeferred: false,
254 })
255 // Records left by sessions that ended before tc took them go first.
256 await flush($)
257 await refresh($)
258 $.clock.every(5 * 60_000, () => {
259 void refresh($)
260 })
261 return started
262 })
263
264 on('turn.complete', async ($, e, next) => {
265 const result = await next(e)
266 const isMain = e.agentId === undefined && e.reason === 'answer'
267 if (isInteractive) {
268 // Outside the turn's dispatch, so tc and the summary never hold up the prompt.
269 $.clock.after(0, () => void afterTurn($, isMain))
270 } else if (isMain) {
271 // A headless run (claude -p, CI) ends with its turn: record before it does.
272 await afterTurn($, isMain)
273 }
274 return result
275 })
276
277 on('session.end', async ($, e, next) => {
278 // All session.end hooks share 1.5 seconds. What tc does not take now
279 // stays in the store and goes at the next session's start.
280 await Promise.race([flush($), $.clock.sleep(1_000)])
281 return next(e)
282 })
283
284 on('command.run', { command: 'tc' }, async ($, e) => {
285 const command = parseCommand(e.args)
286 switch (command.kind) {
287 case 'open':
288 await openPane($)
289 return { text: 'Opened the Token Controller pane.' }
290 case 'tags':
291 return { text: await setTags($, command.tags) }
292 case 'report': {
293 const run = await tc($, ['report', '--by', command.by, '--json'])
294 if (run.missing) return { text: INSTALL }
295 if (!run.ok) return { text: reason(run.stderr, run.stdout, 'tc report failed') }
296 return {
297 text: reportText(
298 readReport(parseJson(run.stdout), command.by),
299 e.presentation.columns - 4,
300 ),
301 }
302 }
303 case 'submit':
304 return { text: await submit($) }
305 case 'help':
306 return { text: [command.problem, HELP].filter(Boolean).join('\n\n') }
307 }
308 })
309
310 on('tool.call', { tool: TOOL }, async ($, e) => {
311 const input = e.input as { by?: unknown; filter?: unknown }
312 const by = typeof input.by === 'string' && input.by !== '' ? input.by : 'ticket'
313 const args = ['report', '--by', by, '--json']
314 if (typeof input.filter === 'string' && input.filter !== '') args.push('--filter', input.filter)
315 const run = await tc($, args)
316 if (run.missing) return { result: INSTALL }
317 if (!run.ok) return { result: reason(run.stderr, run.stdout, 'tc report failed') }
318 return { result: reportTable(readReport(parseJson(run.stdout), by)) }
319 }).catch(() => ({ deny: 'Token Controller could not make the report.' }))
320
321 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
322 if (e.props.hasSurvey) return next(e)
323 const [current, trouble, sessionTicketNow] = await Promise.all([
324 read($, sheet),
325 read($, problem),
326 read($, ticket),
327 ])
328 if (current === null && trouble === null) return next(e)
329 const { Box, Text, Button } = $.ui.resolve(e)
330 const note = current === null ? null : sheetNote(current)
331 // A note gets its own row when the band may take two, so the headline never wraps.
332 const isNoteBelow = note !== null && e.props.maxRows >= 2
333 const top = (
334 <Box flexDirection="row" gap={1}>
335 {current !== null && <Text wrap="truncate-end">{headline(current, sessionTicketNow)}</Text>}
336 {note !== null && !isNoteBelow && (
337 <Text color="warning" wrap="truncate-end">
338 · {note}
339 </Text>
340 )}
341 {trouble !== null && (
342 <Text color="warning" wrap="truncate-end">
343 {trouble}
344 </Text>
345 )}
346 <Button key="open" plain dimColor label="details" onPress={() => void openPane($)} />
347 </Box>
348 )
349 if (!isNoteBelow) return top
350 return (
351 <Box flexDirection="column">
352 {top}
353 <Text color="warning" wrap="truncate-end">
354 {note}
355 </Text>
356 </Box>
357 )
358 })
359
360 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
361 const elements = $.ui.resolve(e)
362 const { Box, Text, Button } = elements
363 // The mobile app draws no text field yet: there the pane shows and submits, and fixes wait for a computer.
364 const Input = 'Input' in elements ? elements.Input : undefined
365 const [current, trouble, shown, rows, chosen, said, sessionTicketNow, now] = await Promise.all([
366 read($, sheet),
367 read($, problem),
368 read($, tab),
369 read($, review),
370 read($, editing),
371 read($, notice),
372 read($, ticket),
373 $.clock.now(),
374 ])
375 const width = Math.max(30, e.props.bodyColumns)
376 const note = current === null ? null : sheetNote(current)
377 const drafts = rows.filter(row => !row.isRejected)
378 const rejected = rows.length - drafts.length
379 const show = (next: Tab) => async () => {
380 await update($, tab, () => next)
381 await update($, notice, () => null)
382 if (next === 'report') await refreshReport($)
383 if (next === 'rules') await refreshRules($)
384 if (next === 'review') await refresh($)
385 }
386 const say = (text: string) => update($, notice, () => text)
387
388 const header = (
389 <Box flexDirection="column">
390 {current !== null && <Text bold>{headline(current, sessionTicketNow)}</Text>}
391 {current !== null && (
392 <Text dimColor wrap="truncate-end">
393 {[
394 current.organization ?? 'Local only: tc connect to report to your organization',
395 `${drafts.length} draft token ${drafts.length === 1 ? 'entry' : 'entries'}`,
396 ...(rejected > 0 ? [`${rejected} rejected`] : []),
397 ].join(' · ')}
398 </Text>
399 )}
400 {note !== null && <Text color="warning">{note}</Text>}
401 {trouble !== null && <Text color="warning">{trouble}</Text>}
402 <Box flexDirection="row" gap={1} marginTop={1}>
403 <Button
404 key="tab-review"
405 label="Review"
406 hotkey="r"
407 variant={shown === 'review' ? 'primary' : 'secondary'}
408 onPress={show('review')}
409 />
410 <Button
411 key="tab-report"
412 label="Report"
413 hotkey="p"
414 variant={shown === 'report' ? 'primary' : 'secondary'}
415 onPress={show('report')}
416 />
417 <Button
418 key="tab-rules"
419 label="Rules"
420 hotkey="u"
421 variant={shown === 'rules' ? 'primary' : 'secondary'}
422 onPress={show('rules')}
423 />
424 </Box>
425 {said !== null && <Text dimColor>{said}</Text>}
426 </Box>
427 )
428
429 if (shown === 'report') {
430 const [shownReport, by] = await Promise.all([read($, report), read($, reportBy)])
431 const choices = ['ticket', 'project', 'team', 'day']
432 const options = choices.includes(by) ? choices : [...choices, by]
433 const labels = shownReport === null ? [] : shownReport.rows.map(r => rowLabel(r, by))
434 const label = Math.min(20, Math.max(5, ...labels.map(l => l.length)))
435 const barWidth = Math.max(6, width - label - 22)
436 const max = shownReport === null ? 0 : Math.max(0, ...shownReport.rows.map(r => r.cost))
437 return (
438 <Box flexDirection="column" gap={1}>
439 {header}
440 <Box flexDirection="row" gap={1}>
441 {options.map(option => (
442 <Button
443 key={`by-${option}`}
444 label={option}
445 variant={option === by ? 'primary' : 'secondary'}
446 onPress={async () => {
447 await update($, reportBy, () => option)
448 await refreshReport($)
449 }}
450 />
451 ))}
452 </Box>
453 {shownReport === null && <Text dimColor>No report yet.</Text>}
454 {shownReport !== null && shownReport.rows.length === 0 && (
455 <Text dimColor>No cost by {shownReport.by} yet.</Text>
456 )}
457 {shownReport !== null && shownReport.rows.length > 0 && (
458 <Box flexDirection="column">
459 {shownReport.rows.map((row, i) => (
460 <Box key={`row-${row.value ?? ''}`} flexDirection="row" gap={1}>
461 <Text wrap="truncate-end">{(labels[i] ?? '').slice(0, label).padEnd(label)}</Text>
462 <Text>{money(row.cost, shownReport.currency, true).padStart(10)}</Text>
463 <Text dimColor>{(row.share === null ? '' : percent(row.share)).padStart(4)}</Text>
464 <Text color="claude">{bar(row.cost, max, barWidth)}</Text>
465 </Box>
466 ))}
467 <Text bold>
468 {'total'.padEnd(label)}{' '}
469 {money(shownReport.total, shownReport.currency, true).padStart(10)}
470 </Text>
471 </Box>
472 )}
473 </Box>
474 )
475 }
476
477 if (shown === 'rules') {
478 const listed = await read($, rules)
479 return (
480 <Box flexDirection="column" gap={1}>
481 {header}
482 {listed.length === 0 && (
483 <Text dimColor>No personal rules yet. Your organization’s rules still apply.</Text>
484 )}
485 <Box flexDirection="column">
486 {listed.map(rule => (
487 <Box key={`rule-${rule.number}`} flexDirection="row" gap={1}>
488 <Text dimColor>{String(rule.number).padStart(2)}</Text>
489 <Text wrap="truncate-end">{rule.text}</Text>
490 <Button
491 key={`remove-${rule.number}`}
492 plain
493 dimColor
494 label="remove"
495 onPress={async () => {
496 const run = await tc($, ['rules', 'remove', String(rule.number), '--json'])
497 await say(
498 run.ok
499 ? `Removed rule ${rule.number}.`
500 : reason(run.stderr, run.stdout, 'tc rules failed'),
501 )
502 await refreshRules($)
503 }}
504 />
505 </Box>
506 ))}
507 </Box>
508 {Input !== undefined && (
509 <Input
510 key="add-rule"
511 label="Add a rule"
512 placeholder="branch starts-with review/ purpose=code-review"
513 submitLabel="Add"
514 onSubmit={async (text: string) => {
515 if (text.trim() === '') return
516 const run = await tc($, ['rules', 'add', text.trim(), '--json'])
517 await say(
518 run.ok ? 'Added the rule.' : reason(run.stderr, run.stdout, 'tc rules failed'),
519 )
520 await refreshRules($)
521 }}
522 />
523 )}
524 </Box>
525 )
526 }
527
528 const total = drafts.reduce((sum, row) => sum + (row.cost ?? 0), 0)
529 const currency = current?.currency ?? 'USD'
530 const costText = (row: (typeof rows)[number]) =>
531 row.cost === null ? 'no price' : money(row.cost, currency, true)
532 const whenWidth = Math.max(0, ...rows.map(row => when(row.startedAt, now).length))
533 const costWidth = Math.max(0, ...rows.map(row => costText(row).length))
534 return (
535 <Box flexDirection="column" gap={1}>
536 {header}
537 {rows.length === 0 && (
538 <Text dimColor>No draft token entries. Each Claude Code session becomes one.</Text>
539 )}
540 <Box flexDirection="column">
541 {rows.map(row => (
542 <Box key={`entry-${row.number}`} flexDirection="column">
543 <Button
544 key={`pick-${row.number}`}
545 plain
546 onPress={() =>
547 update($, editing, open => (open === row.number ? null : row.number))
548 }
549 >
550 {String(row.number).padStart(2)} {when(row.startedAt, now).padEnd(whenWidth)}{' '}
551 {costText(row).padStart(costWidth)} {placeLine(row)}
552 </Button>
553 {row.summary !== '' && (
554 <Text wrap="truncate-end">
555 {' '}
556 {row.summary}
557 </Text>
558 )}
559 <Text
560 dimColor={!row.isRejected}
561 color={row.isRejected ? 'warning' : undefined}
562 wrap="truncate-end"
563 >
564 {' '}
565 {placedByLine(row)}
566 </Text>
567 {Input !== undefined && chosen === row.number && (
568 <Input
569 key={`tag-${row.number}`}
570 label={`Tags for #${row.number}`}
571 placeholder="KD-231, or name=value"
572 submitLabel="Set"
573 autoFocus
574 onSubmit={async (text: string) => {
575 const pairs = text
576 .split(/\s+/)
577 .filter(Boolean)
578 .map((pair: string) => (pair.includes('=') ? pair : `ticket=${pair}`))
579 if (pairs.length === 0) return
580 const run = await tc($, ['tag', String(row.number), ...pairs, '--json'])
581 await say(
582 run.ok
583 ? `#${row.number}: ${pairs.join(' ')}`
584 : reason(run.stderr, run.stdout, 'tc tag failed'),
585 )
586 await update($, editing, () => null)
587 await refresh($)
588 }}
589 />
590 )}
591 </Box>
592 ))}
593 </Box>
594 {drafts.length > 0 && (
595 <Box flexDirection="row" gap={1}>
596 <Button
597 key="submit"
598 variant="primary"
599 hotkey="s"
600 label={`Submit ${drafts.length} ${drafts.length === 1 ? 'entry' : 'entries'} · ${money(total, currency, true)}`}
601 onPress={async () => {
602 await say(await submit($))
603 }}
604 />
605 </Box>
606 )}
607 </Box>
608 )
609 })
610}
611hooks/lib/format.ts 189 lines1import type { Report, ReportRow, ReviewRow, Sheet, Submitted } from './tc.ts'
2
3const SYMBOLS: Record<string, string> = { USD: '$', EUR: '€', GBP: '£' }
4
5/** `$212`, `$41.87`: whole units from 100 up, cents below. */
6export function money(amount: number, currency: string, exact = false): string {
7 const symbol = SYMBOLS[currency]
8 const digits = exact || Math.abs(amount) < 100 ? 2 : 0
9 const text = amount.toLocaleString('en-US', {
10 minimumFractionDigits: digits,
11 maximumFractionDigits: digits,
12 })
13 return symbol === undefined ? `${text} ${currency}` : `${symbol}${text}`
14}
15
16export function percent(share: number): string {
17 return `${Math.round(share * 100)}%`
18}
19
20/**
21 * The headline of the band and the pane: today's cost, the share on tickets,
22 * and this session's ticket, such as `$41.87 today · 91% on tickets · KD-214`.
23 */
24export function headline(sheet: Sheet, ticket: string | null): string {
25 const parts = [`${money(sheet.today, sheet.currency)} today`]
26 if (sheet.shareOnTickets !== null) parts.push(`${percent(sheet.shareOnTickets)} on tickets`)
27 if (ticket) parts.push(ticket)
28 return parts.join(' · ')
29}
30
31const MONTHS = [
32 'January',
33 'February',
34 'March',
35 'April',
36 'May',
37 'June',
38 'July',
39 'August',
40 'September',
41 'October',
42 'November',
43 'December',
44]
45
46/** `September 2026` for `2026-09`; anything else as tc sent it. */
47export function monthName(period: string): string {
48 const match = /^(\d{4})-(\d{2})$/.exec(period)
49 const month = match === null ? undefined : MONTHS[Number(match[2]) - 1]
50 return match === null || month === undefined ? period : `${month} ${match[1]}`
51}
52
53/**
54 * The token sheet's note: late, and the controller's request to submit
55 * before the close, such as `Nadia Berger asks you to submit for September 2026`.
56 */
57export function sheetNote(sheet: Sheet): string | null {
58 const notes: string[] = []
59 if (sheet.isLate) notes.push('token sheet late')
60 const asked = sheet.askedToSubmit
61 if (asked !== null) {
62 const month = monthName(asked.period)
63 notes.push(
64 asked.askedBy === null
65 ? `asked to submit for ${month}`
66 : `${asked.askedBy} asks you to submit for ${month}`,
67 )
68 }
69 return notes.length === 0 ? null : notes.join(' · ')
70}
71
72function tickets(row: ReviewRow): string[] {
73 return [...new Set(row.splits.flatMap(split => (split.tags.ticket ? [split.tags.ticket] : [])))]
74}
75
76/** The ticket tc placed this session on, if any: `KD-214`, or `KD-231 +1` when split. */
77export function sessionTicket(rows: readonly ReviewRow[], sessionId: string): string | null {
78 const row = rows.find(r => r.sessionId === sessionId)
79 if (row === undefined) return null
80 const [first, ...rest] = tickets(row)
81 if (first === undefined) return null
82 return rest.length === 0 ? first : `${first} +${rest.length}`
83}
84
85/** `ticket KD-214 · team web`, the review list's tag column. */
86export function tagLine(tags: Record<string, string>): string {
87 const order = ['ticket', 'project']
88 const names = Object.keys(tags).sort((a, b) => {
89 const ia = order.indexOf(a)
90 const ib = order.indexOf(b)
91 return (ia === -1 ? 9 : ia) - (ib === -1 ? 9 : ib) || a.localeCompare(b)
92 })
93 return names.map(name => `${name} ${tags[name]}`).join(' · ')
94}
95
96/** Where a token entry sits: its tags, or each split's ticket or tags with its share. */
97export function placeLine(row: ReviewRow): string {
98 if (row.splits.length <= 1) return tagLine(row.splits[0]?.tags ?? {}) || 'no tags'
99 return row.splits
100 .map(split => {
101 const place = split.tags.ticket ?? (tagLine(split.tags) || 'no tags')
102 return split.share === null ? place : `${place} ${percent(split.share)}`
103 })
104 .join(' · ')
105}
106
107/** The line under a token entry: what placed it, or why it came back. */
108export function placedByLine(row: ReviewRow): string {
109 if (row.isRejected) return `rejected: ${row.rejectedReason ?? 'see the web app'}`
110 const parts: string[] = []
111 if (row.placedBy !== null && row.placedBy !== 'none') parts.push(`placed by ${row.placedBy}`)
112 else parts.push('not placed')
113 if (row.candidates.length > 0) parts.push(`maybe ${row.candidates.join(', ')}`)
114 return parts.join(' · ')
115}
116
117const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
118
119/** `09:12` today, `Mon 09:12` this week, `2026-09-18` before. */
120export function when(iso: string | null, now: number): string {
121 if (iso === null) return ''
122 const at = new Date(iso)
123 if (Number.isNaN(at.getTime())) return ''
124 const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
125 const days = (now - at.getTime()) / 86_400_000
126 if (new Date(now).toDateString() === at.toDateString()) return time
127 if (days < 6) return `${DAYS[at.getDay()]} ${time}`
128 return at.toISOString().slice(0, 10)
129}
130
131const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
132
133/** A bar of `width` cells for `value` out of `max`, in eighths of a cell. */
134export function bar(value: number, max: number, width: number): string {
135 if (max <= 0 || value <= 0 || width <= 0) return ''
136 const eighths = Math.max(1, Math.round((Math.min(value, max) / max) * width * 8))
137 return '█'.repeat(Math.floor(eighths / 8)) + EIGHTHS[eighths % 8]
138}
139
140/** A row's label: its value, or `(no ticket)` for the cost without one. */
141export function rowLabel(row: ReportRow, by: string): string {
142 return row.value ?? `(no ${by})`
143}
144
145/** A report as a Markdown table, for the model. */
146export function reportTable(report: Report): string {
147 if (report.rows.length === 0) return `No cost by ${report.by} yet.`
148 const lines = [
149 `| ${report.by} | cost | share |`,
150 '| --- | ---: | ---: |',
151 ...report.rows.map(
152 row =>
153 `| ${rowLabel(row, report.by)} | ${money(row.cost, report.currency, true)} | ${row.share === null ? '' : percent(row.share)} |`,
154 ),
155 `| **total** | **${money(report.total, report.currency, true)}** | |`,
156 ]
157 return lines.join('\n')
158}
159
160/** The same report as aligned text with bars, for a command's output row. */
161export function reportText(report: Report, width = 72): string {
162 if (report.rows.length === 0) return `No cost by ${report.by} yet.`
163 const labels = report.rows.map(row => rowLabel(row, report.by))
164 const label = Math.min(24, Math.max(5, ...labels.map(l => l.length)))
165 const costs = report.rows.map(row => money(row.cost, report.currency, true))
166 const costWidth = Math.max(...costs.map(c => c.length))
167 const barWidth = Math.min(40, Math.max(8, width - label - costWidth - 10))
168 const max = Math.max(...report.rows.map(row => row.cost))
169 const lines = report.rows.map((row, i) => {
170 const share = row.share === null ? '' : percent(row.share)
171 return `${(labels[i] ?? '').slice(0, label).padEnd(label)} ${(costs[i] ?? '').padStart(costWidth)} ${share.padStart(4)} ${bar(row.cost, max, barWidth)}`
172 })
173 return [
174 `Cost by ${report.by}`,
175 '',
176 ...lines,
177 `${'total'.padEnd(label)} ${money(report.total, report.currency, true).padStart(costWidth)}`,
178 ].join('\n')
179}
180
181/** What `tc submit` did, in one line. */
182export function submittedText(done: Submitted): string {
183 if (done.count === 0) return 'Nothing to submit.'
184 const parts = [`Submitted ${done.count} token ${done.count === 1 ? 'entry' : 'entries'}`]
185 if (done.cost !== null) parts.push(money(done.cost, done.currency, true))
186 if (done.shareOnTickets !== null) parts.push(`${percent(done.shareOnTickets)} on tickets`)
187 return parts.join(' · ')
188}
189hooks/lib/records.ts 85 lines1/**
2 * What the mod hands to `tc record`: one JSON object with what only Claude
3 * Code knows about a session, its summary and the tags the person set by
4 * hand. tc reads the session's usage, branches and prompts from Claude Code's
5 * own session files, builds the draft token entry and runs the rules (ADR
6 * 0010). Each record is the whole picture so far, so sending it twice changes
7 * nothing.
8 */
9export interface SessionRecord {
10 agent_harness: 'claude-code'
11 session_id: string
12 summary?: string
13 ticket?: string
14 /** Every tag but the ticket the person set by hand this session. */
15 tags?: Record<string, string>
16}
17
18export function sessionRecord(
19 sessionId: string,
20 summary: string | null,
21 handTags: Readonly<Record<string, string>>,
22): SessionRecord {
23 const { ticket, ...tags } = handTags
24 return {
25 agent_harness: 'claude-code',
26 session_id: sessionId,
27 ...(summary === null ? {} : { summary }),
28 ...(ticket === undefined ? {} : { ticket }),
29 ...(Object.keys(tags).length === 0 ? {} : { tags }),
30 }
31}
32
33/**
34 * Reads `/tc` arguments: `ticket KD-214`, `tag purpose=spike`, `report team`,
35 * `submit`, or nothing (opens the pane).
36 */
37export type Command =
38 | { kind: 'open' }
39 | { kind: 'tags'; tags: Record<string, string> }
40 | { kind: 'report'; by: string }
41 | { kind: 'submit' }
42 | { kind: 'help'; problem?: string }
43
44export function parseCommand(args: string): Command {
45 const words = args.trim().split(/\s+/).filter(Boolean)
46 const [verb, ...rest] = words
47 switch (verb) {
48 case undefined:
49 return { kind: 'open' }
50 case 'ticket':
51 if (rest.length !== 1 || rest[0] === undefined) {
52 return { kind: 'help', problem: 'Name one ticket, such as /tc ticket KD-214.' }
53 }
54 return { kind: 'tags', tags: { ticket: rest[0] } }
55 case 'tag': {
56 if (rest.length === 0) {
57 return { kind: 'help', problem: 'Name a tag, such as /tc tag purpose=spike.' }
58 }
59 const tags: Record<string, string> = {}
60 for (const pair of rest) {
61 const at = pair.indexOf('=')
62 if (at <= 0 || at === pair.length - 1) {
63 return { kind: 'help', problem: `"${pair}" is not name=value.` }
64 }
65 tags[pair.slice(0, at)] = pair.slice(at + 1)
66 }
67 return { kind: 'tags', tags }
68 }
69 case 'report':
70 return { kind: 'report', by: rest[0] ?? 'ticket' }
71 case 'submit':
72 return { kind: 'submit' }
73 default:
74 return { kind: 'help', problem: `/tc has no "${verb}".` }
75 }
76}
77
78export const HELP = [
79 '/tc ticket KD-214 set this session’s ticket',
80 '/tc tag purpose=spike set any other tag',
81 '/tc report [by] a report table, by ticket, project, any tag or day',
82 '/tc submit submit your token sheet',
83 '/tc open the pane',
84].join('\n')
85hooks/lib/summary.ts 61 lines1/**
2 * The session's summary: one line on what the session did, written during the
3 * session by a small model call, so it exists even if the session is closed
4 * abruptly. It is the only text of a token entry besides the tag values, so
5 * it names the work and never quotes code, secrets or customer data.
6 */
7
8export const SUMMARY_MODEL = 'haiku'
9
10export const SUMMARY_SYSTEM = [
11 'You write the one-line summary of a coding agent session for a cost report.',
12 'Say what the work was, in plain words, as a person would log it on a timesheet:',
13 'for example "Fixed rounding in the checkout total and added tests" or',
14 '"Reviewed pull request 88 for the search page".',
15 'One sentence, at most 120 characters, no quotes, no trailing period.',
16 'Never include code, file contents, secrets, names of customers or personal data.',
17 'Answer with the sentence alone.',
18].join(' ')
19
20export interface Message {
21 role: 'user' | 'assistant'
22 text: string
23}
24
25const PER_MESSAGE = 1500
26const IN_ALL = 12_000
27
28/** The prompt: the summary so far and the latest messages, newest last. */
29export function summaryPrompt(previous: string | null, messages: readonly Message[]): string {
30 const picked: string[] = []
31 let room = IN_ALL
32 for (const message of [...messages].reverse()) {
33 const text = message.text.trim()
34 if (text === '') continue
35 const cut = text.length > PER_MESSAGE ? `${text.slice(0, PER_MESSAGE)}…` : text
36 const line = `${message.role === 'user' ? 'Person' : 'Agent'}: ${cut}`
37 if (line.length > room) break
38 picked.unshift(line)
39 room -= line.length
40 }
41 return [
42 previous ? `The summary so far: ${previous}` : 'There is no summary yet.',
43 '',
44 'The latest messages of the session:',
45 ...picked,
46 '',
47 'Write the summary of the whole session so far.',
48 ].join('\n')
49}
50
51/** The model's answer as one clean line, or null when it gave none. */
52export function cleanSummary(answer: string): string | null {
53 const line = answer
54 .split('\n')
55 .map(part => part.trim())
56 .find(part => part !== '')
57 if (line === undefined) return null
58 const bare = line.replace(/^["'“”]+|["'“”]+$/g, '').replace(/\.$/, '')
59 return bare.length > 200 ? `${bare.slice(0, 199)}…` : bare
60}
61hooks/lib/tc.ts 210 lines1/**
2 * The parts of `tc --json` output the mod reads. tc owns local state, rules
3 * and submission (ADR 0010); the mod only shows what tc says and passes on
4 * what the person does. Every reader here is lenient: a field tc leaves out
5 * reads as unknown, never as a crash.
6 */
7
8/** A controller's request to submit before the monthly close. */
9export interface AskedToSubmit {
10 /** The month, such as `2026-09`. */
11 period: string
12 askedBy: string | null
13}
14
15/** `tc status --json`: today's cost and the token sheet. */
16export interface Sheet {
17 isConnected: boolean
18 organization: string | null
19 currency: string
20 /** Today's list value. */
21 today: number
22 /** 0 to 1, or null while there is nothing to share. */
23 shareOnTickets: number | null
24 isLate: boolean
25 askedToSubmit: AskedToSubmit | null
26}
27
28export interface Split {
29 /** 0 to 1, or null when tc does not say. */
30 share: number | null
31 tags: Record<string, string>
32}
33
34/** One token entry of `tc review --json`. */
35export interface ReviewRow {
36 number: number
37 sessionId: string | null
38 startedAt: string | null
39 /** The list value, or null while the model has no price. */
40 cost: number | null
41 summary: string
42 isRejected: boolean
43 rejectedReason: string | null
44 placedBy: string | null
45 candidates: string[]
46 splits: Split[]
47}
48
49export interface ReportRow {
50 /** The tag value, or null for the cost that has none. */
51 value: string | null
52 cost: number
53 /** 0 to 1 of the total. */
54 share: number | null
55}
56
57export interface Report {
58 by: string
59 currency: string
60 rows: ReportRow[]
61 total: number
62}
63
64export interface Rule {
65 /** Its place in the list, as `tc rules remove N` takes it. */
66 number: number
67 text: string
68}
69
70/** `tc submit --json`. */
71export interface Submitted {
72 count: number
73 cost: number | null
74 currency: string
75 shareOnTickets: number | null
76}
77
78type Json = Record<string, unknown>
79
80function object(value: unknown): Json {
81 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : {}
82}
83
84/** A number, or a decimal string as tc sends money. */
85function num(value: unknown): number | null {
86 if (typeof value === 'number' && Number.isFinite(value)) return value
87 if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
88 return Number(value)
89 }
90 return null
91}
92
93function str(value: unknown): string | null {
94 return typeof value === 'string' && value !== '' ? value : null
95}
96
97function fraction(percent: unknown): number | null {
98 const n = num(percent)
99 return n === null ? null : n / 100
100}
101
102function tags(value: unknown): Record<string, string> {
103 const out: Record<string, string> = {}
104 for (const [name, v] of Object.entries(object(value))) {
105 if (typeof v === 'string') out[name] = v
106 }
107 return out
108}
109
110function array(value: unknown): unknown[] {
111 return Array.isArray(value) ? value : []
112}
113
114/** A list, bare or under `key`. */
115function list(value: unknown, key: string): unknown[] {
116 return Array.isArray(value) ? value : array(object(value)[key])
117}
118
119export function parseJson(text: string): unknown {
120 try {
121 return JSON.parse(text)
122 } catch {
123 return undefined
124 }
125}
126
127export function readSheet(value: unknown): Sheet {
128 const top = object(value)
129 const today = object(top.today)
130 const asked = object(top.asked_to_submit)
131 const period = str(asked.period)
132 return {
133 isConnected: top.connected === true,
134 organization: str(top.organization),
135 currency: str(today.currency) ?? 'USD',
136 today: num(today.list_value) ?? 0,
137 shareOnTickets: fraction(today.share_on_tickets_percent),
138 isLate: object(top.token_sheet).late === true,
139 askedToSubmit: period === null ? null : { period, askedBy: str(asked.asked_by) },
140 }
141}
142
143export function readEntry(value: unknown): ReviewRow | null {
144 const row = object(value)
145 const number = num(row.number)
146 if (number === null) return null
147 return {
148 number,
149 sessionId: str(row.session_id),
150 startedAt: str(row.started_at),
151 cost: num(row.list_value),
152 summary: str(row.summary) ?? '',
153 isRejected: row.status === 'rejected',
154 rejectedReason: str(row.rejected_reason),
155 placedBy: str(row.placed_by),
156 candidates: array(row.candidates).flatMap(key => (typeof key === 'string' ? [key] : [])),
157 splits: array(row.splits).map(item => {
158 const split = object(item)
159 return { share: fraction(split.share_percent), tags: tags(split.tags) }
160 }),
161 }
162}
163
164export function readReview(value: unknown): ReviewRow[] {
165 return list(value, 'token_entries').flatMap(item => {
166 const row = readEntry(item)
167 return row === null ? [] : [row]
168 })
169}
170
171export function readReport(value: unknown, by: string): Report {
172 const top = object(value)
173 const rows = list(value, 'rows').flatMap(item => {
174 const row = object(item)
175 const cost = num(row.list_value)
176 return cost === null ? [] : [{ value: str(row.value), cost }]
177 })
178 const total = num(top.total) ?? rows.reduce((sum, row) => sum + row.cost, 0)
179 return {
180 by: str(top.by) ?? by,
181 currency: str(top.currency) ?? 'USD',
182 rows: rows.map(row => ({ ...row, share: total > 0 ? row.cost / total : null })),
183 total,
184 }
185}
186
187export function readRules(value: unknown): Rule[] {
188 return list(value, 'rules').flatMap((item, index) => {
189 const row = object(item)
190 const text = str(row.text) ?? str(row.name)
191 return text === null ? [] : [{ number: num(row.number) ?? index + 1, text }]
192 })
193}
194
195export function readSubmitted(value: unknown): Submitted {
196 const top = object(value)
197 return {
198 count: num(top.submitted) ?? 0,
199 cost: num(top.list_value),
200 currency: str(top.currency) ?? 'USD',
201 shareOnTickets: fraction(top.share_on_tickets_percent),
202 }
203}
204
205/** The first line tc wrote on stderr, or a plain fallback: what the person reads. */
206export function reason(stderr: string, stdout: string, fallback: string): string {
207 const line = (stderr.trim() || stdout.trim()).split('\n')[0]?.trim()
208 return line || fallback
209}
210types/index.d.ts 59 lines1// The values the Token Controller mod keeps for a session. What tc says is
2// mirrored here so the band and the pane draw without calling tc.
3
4export type AskedToSubmit = { period: string; askedBy: string | null }
5
6export type Sheet = {
7 isConnected: boolean
8 organization: string | null
9 currency: string
10 today: number
11 shareOnTickets: number | null
12 isLate: boolean
13 askedToSubmit: AskedToSubmit | null
14}
15
16export type Split = { share: number | null; tags: Record<string, string> }
17
18export type ReviewRow = {
19 number: number
20 sessionId: string | null
21 startedAt: string | null
22 cost: number | null
23 summary: string
24 isRejected: boolean
25 rejectedReason: string | null
26 placedBy: string | null
27 candidates: string[]
28 splits: Split[]
29}
30
31export type ReportRow = { value: string | null; cost: number; share: number | null }
32
33export type Report = { by: string; currency: string; rows: ReportRow[]; total: number }
34
35export type Rule = { number: number; text: string }
36
37export type Tab = 'review' | 'report' | 'rules'
38
39declare module 'claude-code' {
40 interface PluginState {
41 'token-controller': {
42 sheet: Sheet | null
43 review: ReviewRow[]
44 report: Report | null
45 reportBy: string
46 rules: Rule[]
47 tab: Tab
48 editing: number | null
49 notice: string | null
50 problem: string | null
51 /** Where tc placed this session, for the band. */
52 ticket: string | null
53 /** The tags the person set by hand this session, ticket included. */
54 handTags: Record<string, string>
55 summary: string | null
56 }
57 }
58}
59