Saves how long every turn took as a row in the chat, logs every prompt's timing, and adds /timings to look back.

What it does: records how long every prompt took, so you can look back later.
Where you see it:
⏱ 6m 41s · 23 tool calls · 12k tokens written · Opus 5.5. It is saved in the transcript, so it's still there when you reopen the session. Claude never reads it (no token cost)./timings: the last 15 prompts in this project, with average, median, total and longest./timings all: every project, plus a per-project summary./timings 40: the last 40.How it works: the app reports each turn's exact wall-clock length when it ends. The mod adds tool-call count, output tokens and model, then appends the row.
Cost: none. No model calls.
Data saved: ~/.claude/mods-data/turn-timer/<session>.json, one file per session (so parallel sessions never overwrite each other). Each record holds the time, project, first 140 characters of the prompt, duration, tool calls, output tokens and model. Delete the folder to wipe history.
Notes: "interrupted" marks turns you stopped. The desktop app does not show the terminal's "Worked for…" line, which is why this exists.
hooks/register.tsx 196 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// One record per finished main-loop turn. Each session writes its own file
4// (mods-data/turn-timer/<session>.json), so parallel sessions never overwrite
5// each other; /timings reads them all.
6type Entry = {
7 at: number
8 project: string
9 session: string
10 prompt: string
11 ms: number
12 tools: number
13 outTokens: number
14 model: string
15 end: string
16}
17
18type Turn = { turnId: string; prompt: string; tools: number }
19
20const MAX_PER_SESSION = 2000
21
22const fmt = (ms: number) => {
23 const s = Math.max(0, Math.round(ms / 1000))
24 if (s < 60) return `${s}s`
25 const m = Math.floor(s / 60)
26 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
27 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
28}
29
30const kTok = (n: number) =>
31 n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
32
33// claude-opus-5-5 -> Opus 5.5
34const prettyModel = (id: string) => {
35 const m = /claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
36 return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}.${m[3]}` : id
37}
38
39// A worktree under <repo>/.claude/worktrees/<name> still counts as <repo>.
40const projectOf = (dir: string) => {
41 const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
42 return main.split(/[\\/]/).filter(Boolean).pop() ?? main
43}
44
45const oneLine = (text: string, max: number) => {
46 const flat = text.replace(/\s+/g, ' ').trim()
47 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
48}
49
50const pad2 = (n: number) => String(n).padStart(2, '0')
51const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
52const stamp = (ms: number) => {
53 const d = new Date(ms)
54 return `${MONTHS[d.getMonth()]} ${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
55}
56
57const dataDir = async ($: EngineInterface) => {
58 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
59 return `${home.replace(/\\/g, '/')}/.claude/mods-data/turn-timer`
60}
61
62const readAll = async ($: EngineInterface): Promise<Entry[]> => {
63 const dir = await dataDir($)
64 if (!(await $.fs.exists(dir))) return []
65 const files = (await $.fs.list(dir)).filter(f => f.name.endsWith('.json'))
66 const all: Entry[] = []
67 for (const f of files) {
68 try {
69 const list = JSON.parse(await $.fs.read(`${dir}/${f.name}`)) as Entry[]
70 all.push(...list)
71 } catch {
72 // a file mid-write or hand-edited: skip it
73 }
74 }
75 return all.sort((a, b) => a.at - b.at)
76}
77
78const median = (xs: number[]) => {
79 if (xs.length === 0) return 0
80 const s = [...xs].sort((a, b) => a - b)
81 return s[Math.floor(s.length / 2)]!
82}
83
84export const register: Register = on => {
85 let turn: Turn | null = null
86 let mine: Entry[] | null = null
87
88 on('session.start', async ($, e, next) => {
89 await $.command.register({
90 name: 'timings',
91 description: 'How long your prompts took: this project, or "all"',
92 argumentHint: '[all | <count>]',
93 })
94 return next(e)
95 })
96
97 on('turn.start', ($, e, next) => {
98 turn = { turnId: e.turnId, prompt: e.text, tools: 0 }
99 return next(e)
100 })
101
102 on('tool.call', ($, e, next) => {
103 if (e.agentId === undefined && turn) turn.tools += 1
104 return next(e)
105 })
106
107 on('turn.complete', async ($, e, next) => {
108 const done = await next(e)
109 if (e.agentId !== undefined) return done
110
111 const tools = turn?.turnId === e.turnId ? turn.tools : 0
112 const prompt = turn?.turnId === e.turnId ? turn.prompt : ''
113 turn = null
114
115 const outTokens = e.usage?.output_tokens ?? 0
116 const model = e.usage?.model ?? (await $.session.model())
117 const end = e.isAborted ? 'interrupted' : e.reason === 'answer' ? 'done' : e.reason
118
119 const parts = [`⏱ ${fmt(e.durationMs)}`]
120 if (tools > 0) parts.push(`${tools} tool call${tools === 1 ? '' : 's'}`)
121 if (outTokens > 0) parts.push(`${kTok(outTokens)} tokens written`)
122 parts.push(prettyModel(model))
123 if (end !== 'done') parts.push(`(${end})`)
124
125 // A system row is stored in the transcript (so it is there when the session
126 // is reopened) and the model never reads it.
127 void $.session
128 .append({ message: { type: 'system', content: [{ type: 'text', text: parts.join(' · ') }] } })
129 .catch(() => $.ui.log(parts.join(' · ')))
130
131 void (async () => {
132 const session = await $.session.id()
133 const dir = await dataDir($)
134 const file = `${dir}/${session}.json`
135 if (mine === null) {
136 mine = (await $.fs.exists(file))
137 ? ((JSON.parse(await $.fs.read(file)) as Entry[]) ?? [])
138 : []
139 }
140 mine.push({
141 at: await $.clock.now(),
142 project: projectOf(await $.session.cwd()),
143 session,
144 prompt: oneLine(prompt, 140),
145 ms: e.durationMs,
146 tools,
147 outTokens,
148 model,
149 end,
150 })
151 mine = mine.slice(-MAX_PER_SESSION)
152 await $.fs.write(file, JSON.stringify(mine))
153 })().catch(err => $.ui.log(`turn-timer: could not save history (${String(err)})`, { to: 'debug' }))
154
155 return done
156 })
157
158 on('command.run', { command: 'timings' }, async ($, e) => {
159 const arg = e.args.trim().toLowerCase()
160 const isAll = arg === 'all'
161 const count = Number.parseInt(arg, 10) > 0 ? Number.parseInt(arg, 10) : 15
162 const project = projectOf(await $.session.cwd())
163 const all = await readAll($)
164 const rows = isAll ? all : all.filter(x => x.project === project)
165 if (rows.length === 0) {
166 return { text: `No timings saved yet${isAll ? '' : ` for ${project}`}. They are recorded after each turn.` }
167 }
168
169 const real = rows.filter(x => x.prompt !== '')
170 const lines: string[] = []
171 lines.push(`Turn timings · ${isAll ? 'all projects' : project} · last ${Math.min(count, rows.length)} of ${rows.length}`)
172 lines.push('')
173 for (const x of rows.slice(-count)) {
174 const where = isAll ? `${x.project.padEnd(14).slice(0, 14)} ` : ''
175 lines.push(`${stamp(x.at)} ${fmt(x.ms).padStart(8)} ${where}${x.prompt === '' ? '(continued)' : oneLine(x.prompt, 70)}`)
176 }
177 const ms = real.map(x => x.ms)
178 const longest = real.reduce((a, b) => (b.ms > a.ms ? b : a), real[0] ?? rows[0]!)
179 lines.push('')
180 lines.push(
181 `Average ${fmt(ms.reduce((a, b) => a + b, 0) / Math.max(1, ms.length))} · median ${fmt(median(ms))} · ` +
182 `total ${fmt(rows.reduce((a, b) => a + b.ms, 0))}`,
183 )
184 lines.push(`Longest ${fmt(longest.ms)} on ${stamp(longest.at)}: "${oneLine(longest.prompt, 60)}"`)
185 if (isAll) {
186 const by = new Map<string, number[]>()
187 for (const x of real) by.set(x.project, [...(by.get(x.project) ?? []), x.ms])
188 lines.push('')
189 for (const [name, xs] of [...by].sort((a, b) => b[1].length - a[1].length)) {
190 lines.push(`${name.padEnd(16)} ${String(xs.length).padStart(4)} prompts · median ${fmt(median(xs))} · total ${fmt(xs.reduce((a, b) => a + b, 0))}`)
191 }
192 }
193 return { text: '```\n' + lines.join('\n') + '\n```' }
194 })
195}
196