SLOPSHOPPER

time-tracker

Tracks how long Claude was active: turns, model generation, tool runs and subagents, per session, per day and in total.

newpaneguardcommandstatustimer
v0.1.0no licenseupdated 2026-10-10antonisPanos/claude-mods/mods/time-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-tracker
│ ┃ Time tracker ✕ › fix the failing auth test and add an audit log call │ ┃ Session Today All │ ┃ time ⏺ Read(src/auth.ts) │ ┃ Open 0s 0s ⎿ Read 6 lines │ ┃ 0s ⏺ Update(src/auth.ts) │ ┃ Claude active 42s 42s ⎿ Added 2 lines, removed 1 line │ ┃ 42s ⏺ Bash(bun test) │ ┃ generating 0s 0s ⎿ 3 pass, 1 fail │ ┃ 0s │ ┃ in tools 0s 0s ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 0s │ ┃ in subagents 0s 0s ✻ Worked for 42s · done 4:20 PM │ ┃ 0s │ ┃ Idle 0s 0s › /time │ ┃ 0s ⎿ time-tracker: Time tracker pane opened. │ ┃ Turns 1 1 │ ┃ 1 │ ┃ interrupted 0 0 │ ┃ 0 │ ┃ Model requests 0 0 │ ┃ 0 │ ┃ Tool calls 9 9 │ ┃ 9 │ ┃ Subagent runs 0 0 │ ┃ 0 │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Time tracker
Session Today All time Open 0s 0s 0s Claude active 42s 42s 42s generating 0s 0s 0s in tools 0s 0s 0s in subagents 0s 0s 0s Idle 0s 0s 0s Turns 1 1 1 interrupted 0 0 0 Model requests 0 0 0 Tool calls 9 9 9 Subagent runs 0 0 0 Slowest tools this session Bash 0s 4 calls Write 0s 2 calls Read 0s 1 calls Grep 0s 1 calls Edit 0s 1 calls 0 earlier sessions on record. Tool and generation times are summed, so parallel work can add up to more than the wall clock.
README

time-tracker

A Claude Code mod that tracks how long Claude was active.

What it tracks

MeasureHow
OpenWall-clock time the session was open
Claude activeWall-clock time of each main-loop turn, from prompt to final answer (turn.complete's durationMs)
GeneratingTime each model request spent streaming its response (turn.step), summed over the main loop and subagents
In toolsTime each tool call took to run (tool.call), summed; per-tool breakdown too. Agent/Task calls are counted under subagents instead
In subagentsWall-clock time of each subagent run
IdleOpen minus Claude active
CountsTurns (and how many were interrupted), model requests, tool calls, subagent runs

Generation and tool times are sums, so parallel tool calls and subagents can add up to more than the wall clock. A tool call's time can include the wait for your approval when Claude Code asks for permission.

Where it shows

  • Status line: time: 12m 04s active, and time: turn 42s · 12m 04s active while a turn runs.
  • /time: opens a pane with three columns (this session, today, all time) and the slowest tools of the session. Where no pane can be placed, /time answers with a text summary instead.

Storage

Each session's totals are written to the mod's store ($.store) as session:<id> after every turn and when the session ends. A resumed session keeps adding to its record. "Today" counts the sessions that started today (local time).

Develop

claude --plugin-dir ./mods/time-tracker
claude plugin test mods/time-tracker
Source 3 files
hooks/register.tsx 312 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { History, SessionClock, SessionRecord, Totals } from '../types'
5import {
6  addToolCall,
7  addTotals,
8  dayOf,
9  emptyTotals,
10  foldSegment,
11  formatDuration,
12  liveTotals,
13  topTools,
14} from './totals'
15
16const PANE = 'time-tracker'
17const RECORD = 'session:'
18
19const session = atom({ plugin: 'time-tracker', key: 'session' } as const, null)
20const history = atom({ plugin: 'time-tracker', key: 'history' } as const, null)
21const now = atom({ plugin: 'time-tracker', key: 'now' } as const, 0)
22
23const freshClock = (at: number): SessionClock => ({
24  id: null,
25  startedAt: at,
26  segmentStartedAt: at,
27  turnStartedAt: null,
28  totals: emptyTotals(),
29})
30
31/** Applies `change` to the session's clock, if there is one. */
32const touch = ($: EngineInterface, change: (clock: SessionClock) => SessionClock) =>
33  update($, session, clock => (clock === null ? clock : change(clock)))
34
35/**
36 * Ties the clock to the current session id and writes its record to the store.
37 *
38 * A clock not yet tied to this id (a fresh start, a /clear, a resume) first
39 * takes on whatever the store already holds for the id, so a resumed session
40 * keeps counting from where it stopped instead of overwriting its record.
41 */
42const flush = async ($: EngineInterface) => {
43  const id = await $.session.id()
44  const at = await $.clock.now()
45  const held = await read($, session)
46  const prior =
47    held !== null && held.id !== id
48      ? ((await $.store.get(RECORD + id)) as SessionRecord | undefined)
49      : undefined
50
51  const clock = await touch($, held => {
52    const adopted =
53      held.id === id
54        ? held
55        : {
56            ...held,
57            id,
58            startedAt: prior?.startedAt ?? held.startedAt,
59            totals: prior ? addTotals(prior.totals, held.totals) : held.totals,
60          }
61
62    return foldSegment(adopted, at)
63  })
64
65  if (clock === null) return
66
67  const record: SessionRecord = {
68    id,
69    startedAt: clock.startedAt,
70    updatedAt: at,
71    totals: clock.totals,
72  }
73  await $.store.set(RECORD + id, record)
74}
75
76/** Sums every stored session but the current one: today's and all time. */
77const loadHistory = async ($: EngineInterface): Promise<History> => {
78  const id = await $.session.id()
79  const at = await $.clock.now()
80  const today = dayOf(at)
81  let todayTotals = emptyTotals()
82  let allTotals = emptyTotals()
83  let sessions = 0
84
85  for (const key of await $.store.keys()) {
86    if (!key.startsWith(RECORD) || key === RECORD + id) continue
87
88    const record = (await $.store.get(key)) as SessionRecord | undefined
89    if (record === undefined) continue
90
91    sessions += 1
92    allTotals = addTotals(allTotals, record.totals)
93    if (dayOf(record.startedAt) === today) {
94      todayTotals = addTotals(todayTotals, record.totals)
95    }
96  }
97
98  return { today: todayTotals, all: allTotals, sessions, loadedAt: at }
99}
100
101const statusText = (clock: SessionClock, at: number) => {
102  const active = formatDuration(clock.totals.activeMs)
103
104  return clock.turnStartedAt === null
105    ? `time: ${active} active`
106    : `time: turn ${formatDuration(at - clock.turnStartedAt)} · ${active} active`
107}
108
109const summary = (label: string, totals: Totals) =>
110  [
111    `${label}:`,
112    `  open ${formatDuration(totals.wallMs)}, Claude active ${formatDuration(totals.activeMs)} over ${totals.turns} turns`,
113    `  generating ${formatDuration(totals.generationMs)} over ${totals.steps} requests`,
114    `  tools ${formatDuration(totals.toolMs)} over ${totals.toolCalls} calls, subagents ${formatDuration(totals.subagentMs)} over ${totals.subagentRuns} runs`,
115  ].join('\n')
116
117export const register: Register = on => {
118  let ticks = 0
119
120  on('session.start', async ($, e, next) => {
121    await $.command.register({
122      name: 'time',
123      description: 'Show how long Claude was active: this session, today and all time',
124    })
125
126    const at = await $.clock.now()
127    // A hot reload fires session.start again: keep the clock it already has.
128    await update($, session, clock => clock ?? freshClock(at))
129    await flush($)
130
131    $.clock.every(1000, () => {
132      void (async () => {
133        const at = await $.clock.now()
134        const clock = await read($, session)
135        if (clock === null) return
136
137        $.ui.status(statusText(clock, at))
138        // Redraw the pane every second while a turn runs, every 15 s while idle.
139        ticks += 1
140        if (clock.turnStartedAt !== null || ticks % 15 === 0) {
141          await update($, now, () => at)
142        }
143      })().catch(() => undefined)
144    })
145
146    return next(e)
147  })
148
149  on('turn.start', async ($, e, next) => {
150    const at = await $.clock.now()
151    await touch($, clock => ({ ...clock, turnStartedAt: at }))
152
153    return next(e)
154  })
155
156  on('turn.complete', async ($, e, next) => {
157    if (e.agentId !== undefined) {
158      await touch($, clock => ({
159        ...clock,
160        totals: {
161          ...clock.totals,
162          subagentMs: clock.totals.subagentMs + e.durationMs,
163          subagentRuns: clock.totals.subagentRuns + 1,
164        },
165      }))
166
167      return next(e)
168    }
169
170    await touch($, clock => ({
171      ...clock,
172      turnStartedAt: null,
173      totals: {
174        ...clock.totals,
175        activeMs: clock.totals.activeMs + e.durationMs,
176        turns: clock.totals.turns + 1,
177        interruptedTurns: clock.totals.interruptedTurns + (e.isAborted ? 1 : 0),
178      },
179    }))
180    await flush($)
181
182    return next(e)
183  })
184
185  on('turn.step', async function* ($, e, next) {
186    const startedAt = await $.clock.now()
187    try {
188      return yield* next(e)
189    } finally {
190      const ms = (await $.clock.now()) - startedAt
191      await touch($, clock => ({
192        ...clock,
193        totals: {
194          ...clock.totals,
195          generationMs: clock.totals.generationMs + ms,
196          steps: clock.totals.steps + 1,
197        },
198      }))
199    }
200  })
201
202  on('tool.call', async ($, e, next) => {
203    const startedAt = await $.clock.now()
204    try {
205      return await next(e)
206    } finally {
207      const ms = (await $.clock.now()) - startedAt
208      await touch($, clock => ({ ...clock, totals: addToolCall(clock.totals, String(e.tool), ms) }))
209    }
210  }).catch(($, e, next) => next(e)) // only observes: a failure never holds up the tool
211
212  on('session.end', async ($, e, next) => {
213    await flush($)
214
215    // A /clear or a resume goes on under another id: start that one's clock afresh.
216    if (e.reason === 'clear' || e.reason === 'resume') {
217      const at = await $.clock.now()
218      await update($, session, () => freshClock(at))
219    }
220
221    return next(e)
222  })
223
224  on('command.run', { command: 'time' }, async $ => {
225    await flush($)
226    const loaded = await loadHistory($)
227    await update($, history, () => loaded)
228    await update($, now, () => loaded.loadedAt)
229    const { isPlaced } = await $.ui.open({ id: PANE, title: 'Time tracker' })
230
231    const clock = await read($, session)
232    const current = clock === null ? emptyTotals() : clock.totals
233
234    return {
235      text: isPlaced
236        ? 'Time tracker pane opened.'
237        : [
238            summary('This session', current),
239            summary('Today', addTotals(loaded.today, current)),
240            summary('All time', addTotals(loaded.all, current)),
241          ].join('\n'),
242    }
243  })
244
245  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246    const { Box, Text } = $.ui.resolve(e)
247    const clock = await read($, session)
248    const loaded = await read($, history)
249    const at = await read($, now)
250
251    const current = clock === null ? emptyTotals() : liveTotals(clock, at)
252    const past = loaded ?? { today: emptyTotals(), all: emptyTotals(), sessions: 0, loadedAt: at }
253    const columns: Array<[string, Totals]> = [
254      ['Session', current],
255      ['Today', addTotals(past.today, current)],
256      ['All time', addTotals(past.all, current)],
257    ]
258
259    const rows: Array<[string, (t: Totals) => string]> = [
260      ['Open', t => formatDuration(t.wallMs)],
261      ['Claude active', t => formatDuration(t.activeMs)],
262      ['  generating', t => formatDuration(t.generationMs)],
263      ['  in tools', t => formatDuration(t.toolMs)],
264      ['  in subagents', t => formatDuration(t.subagentMs)],
265      ['Idle', t => formatDuration(Math.max(0, t.wallMs - t.activeMs))],
266      ['Turns', t => String(t.turns)],
267      ['  interrupted', t => String(t.interruptedTurns)],
268      ['Model requests', t => String(t.steps)],
269      ['Tool calls', t => String(t.toolCalls)],
270      ['Subagent runs', t => String(t.subagentRuns)],
271    ]
272
273    const label = 16
274    const cell = 11
275    const pad = (text: string, width: number) => text.padEnd(width)
276    const padLeft = (text: string, width: number) => text.padStart(width)
277
278    return (
279      <Box flexDirection="column">
280        {clock?.turnStartedAt != null && (
281          <Text color="green">Turn running: {formatDuration(at - clock.turnStartedAt)}</Text>
282        )}
283        <Text bold>
284          {pad('', label)}
285          {columns.map(([name]) => padLeft(name, cell)).join('')}
286        </Text>
287        {rows.map(([name, value]) => (
288          <Text>
289            {pad(name, label)}
290            {columns.map(([, totals]) => padLeft(value(totals), cell)).join('')}
291          </Text>
292        ))}
293        <Text> </Text>
294        <Text bold>Slowest tools this session</Text>
295        {topTools(current, 6).length === 0 && <Text dimColor>No tool calls yet.</Text>}
296        {topTools(current, 6).map(([tool, time]) => (
297          <Text>
298            {pad(tool.length > label - 1 ? tool.slice(0, label - 2) + '…' : tool, label)}
299            {padLeft(formatDuration(time.ms), cell)}
300            {padLeft(`${time.calls} calls`, cell)}
301          </Text>
302        ))}
303        <Text> </Text>
304        <Text dimColor>
305          {past.sessions} earlier sessions on record. Tool and generation times are summed, so parallel
306          work can add up to more than the wall clock.
307        </Text>
308      </Box>
309    )
310  })
311}
312
hooks/totals.ts 92 lines
1import type { SessionClock, Totals } from '../types'
2
3/** Tools whose run is a subagent's: their time is counted under subagents. */
4export const AGENT_TOOLS = new Set(['Agent', 'Task'])
5
6export const emptyTotals = (): Totals => ({
7  wallMs: 0,
8  activeMs: 0,
9  generationMs: 0,
10  toolMs: 0,
11  subagentMs: 0,
12  turns: 0,
13  interruptedTurns: 0,
14  steps: 0,
15  toolCalls: 0,
16  subagentRuns: 0,
17  tools: {},
18})
19
20export const addTotals = (a: Totals, b: Totals): Totals => {
21  const tools = { ...a.tools }
22  for (const [name, time] of Object.entries(b.tools)) {
23    const prior = tools[name] ?? { calls: 0, ms: 0 }
24    tools[name] = { calls: prior.calls + time.calls, ms: prior.ms + time.ms }
25  }
26
27  return {
28    wallMs: a.wallMs + b.wallMs,
29    activeMs: a.activeMs + b.activeMs,
30    generationMs: a.generationMs + b.generationMs,
31    toolMs: a.toolMs + b.toolMs,
32    subagentMs: a.subagentMs + b.subagentMs,
33    turns: a.turns + b.turns,
34    interruptedTurns: a.interruptedTurns + b.interruptedTurns,
35    steps: a.steps + b.steps,
36    toolCalls: a.toolCalls + b.toolCalls,
37    subagentRuns: a.subagentRuns + b.subagentRuns,
38    tools,
39  }
40}
41
42export const addToolCall = (totals: Totals, tool: string, ms: number): Totals => {
43  const prior = totals.tools[tool] ?? { calls: 0, ms: 0 }
44
45  return {
46    ...totals,
47    toolCalls: totals.toolCalls + 1,
48    toolMs: AGENT_TOOLS.has(tool) ? totals.toolMs : totals.toolMs + ms,
49    tools: { ...totals.tools, [tool]: { calls: prior.calls + 1, ms: prior.ms + ms } },
50  }
51}
52
53/** The session's totals with the open segment's wall time folded in. */
54export const liveTotals = (session: SessionClock, now: number): Totals => ({
55  ...session.totals,
56  wallMs: session.totals.wallMs + Math.max(0, now - session.segmentStartedAt),
57})
58
59/** Closes the open segment at `now`, so its wall time is in the totals. */
60export const foldSegment = (session: SessionClock, now: number): SessionClock => ({
61  ...session,
62  segmentStartedAt: now,
63  totals: liveTotals(session, now),
64})
65
66/** `1h 02m`, `3m 07s`, `42s`, `0s`. */
67export const formatDuration = (ms: number): string => {
68  const seconds = Math.max(0, Math.floor(ms / 1000))
69  const h = Math.floor(seconds / 3600)
70  const m = Math.floor((seconds % 3600) / 60)
71  const s = seconds % 60
72  const pad = (n: number) => String(n).padStart(2, '0')
73
74  if (h > 0) return `${h}h ${pad(m)}m`
75  if (m > 0) return `${m}m ${pad(s)}s`
76  return `${s}s`
77}
78
79/** Local calendar day of a timestamp, `YYYY-MM-DD`. */
80export const dayOf = (ms: number): string => {
81  const d = new Date(ms)
82  const pad = (n: number) => String(n).padStart(2, '0')
83
84  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
85}
86
87/** The tools that took longest, longest first. */
88export const topTools = (totals: Totals, count: number) =>
89  Object.entries(totals.tools)
90    .sort(([, a], [, b]) => b.ms - a.ms)
91    .slice(0, count)
92
types/index.d.ts 52 lines
1/** Time spent in one tool, summed over its calls. */
2export type ToolTime = { calls: number; ms: number }
3
4/** Everything the tracker sums up, for a session, a day or all time. */
5export type Totals = {
6  /** Wall-clock time the session was open. */
7  wallMs: number
8  /** Wall-clock time of main-loop turns: prompt in to final answer. */
9  activeMs: number
10  /** Time model requests spent streaming a response, every loop summed. */
11  generationMs: number
12  /** Time tools spent running, summed (parallel calls each count). */
13  toolMs: number
14  /** Wall-clock time of subagent runs, summed. */
15  subagentMs: number
16  turns: number
17  interruptedTurns: number
18  steps: number
19  toolCalls: number
20  subagentRuns: number
21  tools: Record<string, ToolTime>
22}
23
24/** The live session: its totals, and the clock segment it is in now. */
25export type SessionClock = {
26  /** The session id the totals belong to; null until adopted (a fresh start, after a /clear). */
27  id: string | null
28  /** When the session was first seen (kept across a resume). */
29  startedAt: number
30  /** When the current process segment began; wall time since is not yet in totals. */
31  segmentStartedAt: number
32  /** When the running main-loop turn began; null while idle. */
33  turnStartedAt: number | null
34  totals: Totals
35}
36
37/** Earlier sessions summed, read from the store when the pane opens. */
38export type History = { today: Totals; all: Totals; sessions: number; loadedAt: number }
39
40/** What a finished or in-progress session leaves in the store. */
41export type SessionRecord = { id: string; startedAt: number; updatedAt: number; totals: Totals }
42
43declare module 'claude-code' {
44  interface PluginState {
45    'time-tracker': {
46      session: SessionClock | null
47      history: History | null
48      now: number
49    }
50  }
51}
52