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

A Claude Code mod that tracks how long Claude was active.
| Measure | How |
|---|---|
| Open | Wall-clock time the session was open |
| Claude active | Wall-clock time of each main-loop turn, from prompt to final answer (turn.complete's durationMs) |
| Generating | Time each model request spent streaming its response (turn.step), summed over the main loop and subagents |
| In tools | Time each tool call took to run (tool.call), summed; per-tool breakdown too. Agent/Task calls are counted under subagents instead |
| In subagents | Wall-clock time of each subagent run |
| Idle | Open minus Claude active |
| Counts | Turns (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.
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.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).
claude --plugin-dir ./mods/time-tracker
claude plugin test mods/time-trackerhooks/register.tsx 312 lines1import { 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}
312hooks/totals.ts 92 lines1import 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)
92types/index.d.ts 52 lines1/** 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