SLOPSHOPPER

token-controller

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.

newpanebandguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-controller
│ ┃ Token Controller ✕ › fix the failing auth test and add an audit log call │ ┃ $0.00 today │ ┃ Local only: tc connect to report to your or… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Review ] [ Report ] [ Rules ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No draft token entries. Each Claude Code ⏺ Bash(bun test) │ ┃ session becomes one. ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tc │ ⎿ token-controller: Opened the Token Controller pane. │ │ $0.00 today details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
$0.00 today details
Pane · Token Controller
$0.00 today Local only: tc connect to report to your organization · 0 d… [ Review ] [ Report ] [ Rules ] No draft token entries. Each Claude Code session becomes one.
README

Token Controller mod for Claude Code

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.

What it does

  • After every turn it runs 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.
  • During the session it writes the one-line summary with a small model call of its own and hands it to 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).
  • A band above the prompt shows $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.
  • A pane (/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.

Working on it

PathWhat it holds
.claude-plugin/plugin.jsonThe manifest. The marketplace that lists it is .claude-plugin/marketplace.json at the repo root.
hooks/register.tsxThe 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.tsThe 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.

Source 6 files
hooks/register.tsx 611 lines
1import 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}
611
hooks/lib/format.ts 189 lines
1import 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}
189
hooks/lib/records.ts 85 lines
1/**
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')
85
hooks/lib/summary.ts 61 lines
1/**
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}
61
hooks/lib/tc.ts 210 lines
1/**
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}
210
types/index.d.ts 59 lines
1// 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