Counts the time you really spend with Claude Code (typing and Claude working, not an idle terminal) and shows it in the status line and /claude-time.

A Claude Code mod that counts the time you really spend with Claude Code. An open terminal does not count. A dev server running in the background does not count.
The mod lives in a folder named active-time because Claude Code reserves plugin names that start with claude-. The command is still /claude-time.
/plugin marketplace add 0xGondarxyz/claude-code-mods
/plugin install active-time@claude-code-mods
Or try it without installing:
git clone https://github.com/0xGondarxyz/claude-code-mods
claude --plugin-dir claude-code-mods/active-time
Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.
Two kinds of active time:
you: you write in the prompt box. Edits with a gap of 120 s or less form one interval. A longer pause starts a new one. Submitting a prompt closes the interval.claude: Claude works. This is each model request and each tool call of any agent of any agent (one tool call counts at most 10 minutes). Background subagents count while they work. Gaps of 30 s or less between these join. The wait of a turn (for example on a question) does not count, only the model requests and tool calls inside it.The total is the union of both kinds. Overlaps count once. Two sessions that work at the same time count once.
CC today 2h14m · week 11h05m
A day is a work day from 05:00 to 05:00 local time, so work after midnight counts for the day before. The week starts on Monday 05:00. It updates after each turn and every 60 s.
/claude-time shows:
you and claudeEach session writes its own file, so sessions never overwrite each other:
~/.claude/active-time/<YYYY-MM-DD>_<sessionId>.json
Content: { "repo": "...", "intervals": [{ "s": ms, "e": ms, "k": "you" | "claude" }] }. A session that crosses 05:00 writes one file per work day, and <YYYY-MM-DD> is the work day. The repo is the git top level of the session folder, or the folder itself.
To reset, delete the folder:
rm -r ~/.claude/active-time
Sessions that started before the mod loaded are rebuilt from the transcripts in ~/.claude/projects, for the last 40 days. This gives Claude time only. Typing time of old sessions cannot be recovered.
claude time when the second one is not a real user prompt and the gap is 10 minutes or less. A prompt starts a wait. A longer gap is a wait too. Gaps of 30 s or less join.claude -p, the SDK) are skipped.~/.claude/active-time/<YYYY-MM-DD>_<sessionId>.bf.json (same shape plus "source": "transcript"). Live files are never touched. Totals take the union of all files, so a session with both kinds counts once.python3 (/usr/bin/python3) and find.Sessions without a person at the prompt (claude -p, the SDK) record nothing, write no file and show no status line.
hooks/register.ts 296 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 PY_SCAN,
4 backfillDays,
5 capTool,
6 changedTranscripts,
7 commandText,
8 compact,
9 dayKey,
10 editStep,
11 lastDayKeys,
12 monthStartKey,
13 parseScan,
14 splitAtWorkday,
15 statusText,
16 submitStep,
17 transcriptId,
18 weekStartKey,
19 type DayFile,
20 type Interval,
21 type Kind,
22 type Span,
23 type Transcript,
24} from './time'
25
26const TICK = 60_000
27const BACKFILL_EVERY = 600_000
28const BACKFILL_DAYS = 40
29
30// Closed intervals of this session by local day. Lost on a reload except what
31// is already in the session's files, which session.start reads back.
32let byDay = new Map<string, Interval[]>()
33let dirty = new Set<string>()
34let cur: Span | undefined
35// False for headless runs (-p, SDK): those are not the person's time.
36let active = false
37let repo = ''
38let dir = ''
39let timer: { cancel: () => void } | undefined
40let bfTimer: { cancel: () => void } | undefined
41let bfRunning = false
42
43async function now($: EngineInterface): Promise<number> {
44 try {
45 return await $.clock.now()
46 } catch {
47 return Date.now()
48 }
49}
50
51function add(s: number, e: number, k: Kind): void {
52 if (!active) return
53 for (const piece of splitAtWorkday({ s, e, k })) {
54 const day = dayKey(piece.s)
55 byDay.set(day, [...(byDay.get(day) ?? []), piece])
56 dirty.add(day)
57 }
58}
59
60async function ensureDir($: EngineInterface): Promise<string> {
61 if (!dir) {
62 const home = await $.env.get('HOME')
63 if (!home) throw new Error('no HOME')
64 dir = `${home}/.claude/active-time`
65 }
66 return dir
67}
68
69async function ensureRepo($: EngineInterface): Promise<string> {
70 if (repo) return repo
71 const cwd = await $.session.cwd()
72 repo = cwd
73 try {
74 const ran = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'])
75 const top = ran.stdout.trim()
76 if (ran.exitCode === 0 && top) repo = top
77 } catch {}
78 return repo
79}
80
81// Closes the open `you` interval when the last edit is older than the gap.
82function closeStaleYou(t: number): void {
83 if (cur && editStep(cur, t).closed) {
84 add(cur.s, cur.e, 'you')
85 cur = undefined
86 }
87}
88
89async function flush($: EngineInterface): Promise<void> {
90 if (dirty.size === 0) return
91 const id = await $.session.id()
92 const where = await ensureDir($)
93 const top = await ensureRepo($)
94 for (const day of [...dirty]) {
95 const intervals = compact(byDay.get(day) ?? [])
96 byDay.set(day, intervals)
97 await $.fs.write(`${where}/${day}_${id}.json`, JSON.stringify({ repo: top, intervals }))
98 dirty.delete(day)
99 }
100}
101
102async function loadFiles($: EngineInterface, fromDay: string): Promise<DayFile[]> {
103 const where = await ensureDir($)
104 let names: string[] = []
105 try {
106 names = (await $.fs.list(where)).map(x => x.name)
107 } catch {}
108 const files: DayFile[] = []
109 for (const name of names) {
110 const m = /^(\d{4}-\d{2}-\d{2})_.+\.(?:bf\.)?json$/.exec(name)
111 if (!m || (m[1] as string) < fromDay) continue
112 try {
113 const body = JSON.parse(await $.fs.read(`${where}/${name}`) as string)
114 files.push({ day: m[1] as string, repo: String(body.repo ?? ''), intervals: body.intervals ?? [] })
115 } catch {}
116 }
117 return files
118}
119
120// Lists the transcripts of the last BACKFILL_DAYS days: sessions and their subagents.
121async function listTranscripts($: EngineInterface): Promise<Transcript[]> {
122 const home = await $.env.get('HOME')
123 if (!home) return []
124 const root = `${home}/.claude/projects`
125 const ran = await $.process.run(
126 ['find', root, '-type', 'f', '-name', '*.jsonl', '-mtime', `-${BACKFILL_DAYS}`, '-printf', '%T@\t%p\n'],
127 { timeoutMs: 60_000 },
128 )
129 const out: Transcript[] = []
130 for (const line of ran.stdout.split('\n')) {
131 const tab = line.indexOf('\t')
132 if (tab < 0) continue
133 const path = line.slice(tab + 1)
134 const rel = path.slice(root.length + 1).split('/')
135 if (rel.length === 2 || (rel.length === 4 && rel[2] === 'subagents')) {
136 out.push({ path, mtimeMs: Math.floor(Number(line.slice(0, tab)) * 1000) })
137 }
138 }
139 return out
140}
141
142// Rebuilds Claude time from transcripts into <day>_<id>.bf.json files. Live files stay untouched.
143async function backfill($: EngineInterface): Promise<void> {
144 if (!active || bfRunning) return
145 bfRunning = true
146 try {
147 const where = await ensureDir($)
148 const seen = ((await $.store.get('backfill')) ?? {}) as Record<string, number>
149 for (const tr of changedTranscripts(await listTranscripts($), seen)) {
150 if (!active) break
151 try {
152 const ran = await $.process.run(['/usr/bin/python3', '-c', PY_SCAN, tr.path], { timeoutMs: 120_000 })
153 const scan = parseScan(ran.stdout)
154 if (ran.exitCode === 0 && !scan.headless) {
155 const id = transcriptId(tr.path)
156 for (const [day, intervals] of backfillDays(scan.events)) {
157 await $.fs.write(
158 `${where}/${day}_${id}.bf.json`,
159 JSON.stringify({ repo: scan.cwd, intervals, source: 'transcript' }),
160 )
161 }
162 }
163 if (ran.exitCode === 0) {
164 seen[tr.path] = tr.mtimeMs
165 await $.store.set('backfill', seen)
166 }
167 } catch {}
168 }
169 await refreshStatus($)
170 } catch {
171 } finally {
172 bfRunning = false
173 }
174}
175
176async function refreshStatus($: EngineInterface): Promise<void> {
177 const t = await now($)
178 $.ui.status(statusText(await loadFiles($, weekStartKey(t)), t))
179}
180
181async function tick($: EngineInterface): Promise<void> {
182 if (!active) return
183 try {
184 closeStaleYou(await now($))
185 await flush($)
186 await refreshStatus($)
187 } catch {}
188}
189
190// Reads this session's own earlier files back so a reload does not drop them.
191async function restore($: EngineInterface): Promise<void> {
192 const id = await $.session.id()
193 const where = await ensureDir($)
194 for (const x of await $.fs.list(where)) {
195 const m = /^(\d{4}-\d{2}-\d{2})_(.+)\.json$/.exec(x.name)
196 if (!m || m[2] !== id) continue
197 try {
198 const body = JSON.parse(await $.fs.read(`${where}/${x.name}`) as string)
199 const day = m[1] as string
200 byDay.set(day, [...(body.intervals ?? []), ...(byDay.get(day) ?? [])])
201 if (body.repo && !repo) repo = body.repo
202 } catch {}
203 }
204}
205
206export const register: Register = (on) => {
207 on('session.start', async ($, e, next) => {
208 try {
209 await $.command.register({ name: 'claude-time', description: 'Active time with Claude Code: today, week, month' })
210 active = e.isInteractive
211 if (!active) return next(e)
212 await ensureRepo($)
213 await restore($)
214 timer?.cancel()
215 timer = $.clock.every(TICK, () => void tick($))
216 await refreshStatus($)
217 bfTimer?.cancel()
218 $.clock.after(0, () => void backfill($))
219 bfTimer = $.clock.every(BACKFILL_EVERY, () => void backfill($))
220 } catch {}
221 return next(e)
222 })
223
224 on('command.run', { command: 'claude-time' }, async $ => {
225 try {
226 const t = await now($)
227 closeStaleYou(t)
228 await flush($)
229 const from = [weekStartKey(t), monthStartKey(t), lastDayKeys(t, 7)[6] as string].sort()[0] as string
230 return { text: commandText(await loadFiles($, from), t) }
231 } catch {
232 return { text: 'claude-time could not read its files.' }
233 }
234 })
235
236 on('prompt.edit', ($, e, next) => {
237 if (active) $.clock.now().then(t => {
238 const step = editStep(cur, t)
239 if (step.closed) add(step.closed.s, step.closed.e, 'you')
240 cur = step.cur
241 }, () => {})
242 return next(e)
243 })
244
245 on('prompt.submit', async ($, e, next) => {
246 try {
247 const closed = submitStep(cur, await now($))
248 if (closed) add(closed.s, closed.e, 'you')
249 cur = undefined
250 } catch {}
251 return next(e)
252 })
253
254 on('turn.complete', async ($, e, next) => {
255 const result = await next(e)
256 await tick($)
257 return result
258 })
259
260 on('tool.call', async ($, e, next) => {
261 const s = await now($)
262 try {
263 return await next(e)
264 } finally {
265 add(s, capTool(s, await now($)), 'claude')
266 }
267 })
268
269 on('turn.step', async function* ($, e, next) {
270 const s = await now($)
271 try {
272 return yield* next(e)
273 } finally {
274 add(s, await now($), 'claude')
275 }
276 })
277
278 on('session.end', async ($, e, next) => {
279 try {
280 if (cur) add(cur.s, cur.e, 'you')
281 cur = undefined
282 if (next.budget.remainingMs > 200) await flush($)
283 } catch {}
284 // /clear keeps the process, and session.start does not fire again.
285 if (e.reason !== 'clear') {
286 timer?.cancel()
287 timer = undefined
288 bfTimer?.cancel()
289 bfTimer = undefined
290 }
291 byDay = new Map()
292 dirty = new Set()
293 return next(e)
294 })
295}
296hooks/time.ts 273 lines1export type Kind = 'you' | 'claude'
2export type Span = { s: number; e: number }
3export type Interval = Span & { k: Kind }
4export type DayFile = { day: string; repo: string; intervals: Interval[] }
5export type Totals = { total: number; you: number; claude: number }
6
7export const YOU_GAP = 120_000
8export const CLAUDE_GAP = 30_000
9export const TOOL_CAP = 600_000
10
11const pad2 = (n: number): string => String(n).padStart(2, '0')
12
13// Sorts by start and joins spans whose gap is at most `gap` ms.
14export function mergeGap(list: readonly Span[], gap: number): Span[] {
15 const sorted = [...list].sort((a, b) => a.s - b.s)
16 const out: Span[] = []
17 for (const x of sorted) {
18 const last = out[out.length - 1]
19 if (last && x.s - last.e <= gap) last.e = Math.max(last.e, x.e)
20 else out.push({ s: x.s, e: x.e })
21 }
22 return out
23}
24
25export const union = (...lists: Span[][]): Span[] => mergeGap(lists.flat(), 0)
26
27export const sum = (list: readonly Span[]): number => list.reduce((n, x) => n + Math.max(0, x.e - x.s), 0)
28
29const WORKDAY_SHIFT = 5 * 3_600_000
30
31// The work day runs from 05:00 to 05:00 local. A day key is the calendar date of (ms - 5 h).
32const keyOf = (d: Date): string => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
33export const dayKey = (ms: number): string => keyOf(new Date(ms - WORKDAY_SHIFT))
34
35// 05:00 local after the work day that holds `ms`.
36export function startOfNextDay(ms: number): number {
37 const d = new Date(ms - WORKDAY_SHIFT)
38 return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 5).getTime()
39}
40
41// Cuts an interval at every 05:00 it crosses.
42export function splitAtWorkday(iv: Interval): Interval[] {
43 const out: Interval[] = []
44 let s = iv.s
45 while (iv.e > s) {
46 const next = startOfNextDay(s)
47 if (iv.e <= next) {
48 out.push({ s, e: iv.e, k: iv.k })
49 break
50 }
51 out.push({ s, e: next, k: iv.k })
52 s = next
53 }
54 return out
55}
56
57function dateOf(key: string): Date {
58 const [y, m, d] = key.split('-').map(Number)
59 return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1)
60}
61
62// Monday of the week (work day), local time.
63export function weekStartKey(ms: number): string {
64 const d = dateOf(dayKey(ms))
65 return keyOf(new Date(d.getFullYear(), d.getMonth(), d.getDate() - ((d.getDay() + 6) % 7)))
66}
67
68export const monthStartKey = (ms: number): string => `${dayKey(ms).slice(0, 8)}01`
69
70// Work day keys of the last n days, today first.
71export function lastDayKeys(ms: number, n: number): string[] {
72 const d = dateOf(dayKey(ms))
73 const out: string[] = []
74 for (let i = 0; i < n; i++) out.push(keyOf(new Date(d.getFullYear(), d.getMonth(), d.getDate() - i)))
75 return out
76}
77
78// One session's closed intervals, joined per kind so the file stays small.
79export function compact(list: readonly Interval[]): Interval[] {
80 const you = mergeGap(list.filter(x => x.k === 'you'), 0).map(x => ({ ...x, k: 'you' as const }))
81 const claude = mergeGap(list.filter(x => x.k === 'claude'), CLAUDE_GAP).map(x => ({ ...x, k: 'claude' as const }))
82 return [...you, ...claude]
83}
84
85// Folds one prompt edit into the open `you` interval. A gap over YOU_GAP closes it.
86export function editStep(cur: Span | undefined, t: number): { cur: Span; closed?: Span } {
87 if (cur && t - cur.e <= YOU_GAP) return { cur: { s: cur.s, e: Math.max(cur.e, t) } }
88 return cur ? { cur: { s: t, e: t }, closed: cur } : { cur: { s: t, e: t } }
89}
90
91// A submit ends the open interval at the submit time, if the last edit was recent.
92export function submitStep(cur: Span | undefined, t: number): Span | undefined {
93 if (!cur) return undefined
94 return t - cur.e <= YOU_GAP ? { s: cur.s, e: Math.max(cur.e, t) } : cur
95}
96
97// The `you` intervals a list of edit times makes, with an optional submit time.
98export function youIntervals(edits: readonly number[], submitAt?: number): Span[] {
99 const out: Span[] = []
100 let cur: Span | undefined
101 for (const t of edits) {
102 const step = editStep(cur, t)
103 if (step.closed) out.push(step.closed)
104 cur = step.cur
105 }
106 if (cur) {
107 const end = submitAt === undefined ? cur : submitStep(cur, submitAt)
108 if (end) out.push(end)
109 }
110 return out
111}
112
113// A tool call counts at most TOOL_CAP.
114export const capTool = (s: number, e: number): number => Math.min(e, s + TOOL_CAP)
115
116export function measure(files: readonly DayFile[]): Totals {
117 const all = files.flatMap(f => f.intervals)
118 const you = mergeGap(all.filter(x => x.k === 'you'), 0)
119 const claude = mergeGap(all.filter(x => x.k === 'claude'), CLAUDE_GAP)
120 return { total: sum(mergeGap([...you, ...claude], 0)), you: sum(you), claude: sum(claude) }
121}
122
123export const inRange = (files: readonly DayFile[], from: string, to = '9999-99-99'): DayFile[] =>
124 files.filter(f => f.day >= from && f.day <= to)
125
126export function fmt(ms: number): string {
127 const m = Math.floor(Math.max(0, ms) / 60_000)
128 const h = Math.floor(m / 60)
129 return h > 0 ? `${h}h${pad2(m % 60)}m` : `${m}m`
130}
131
132const repoName = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
133
134// Union time per repo, biggest first. Repos sharing a folder name join.
135export function perRepo(files: readonly DayFile[], top: number): { repo: string; ms: number }[] {
136 const byName = new Map<string, DayFile[]>()
137 for (const f of files) {
138 const name = repoName(f.repo)
139 byName.set(name, [...(byName.get(name) ?? []), f])
140 }
141 return [...byName.entries()]
142 .map(([repo, fs]) => ({ repo, ms: measure(fs).total }))
143 .filter(r => r.ms > 0)
144 .sort((a, b) => b.ms - a.ms)
145 .slice(0, top)
146}
147
148export function statusText(files: readonly DayFile[], now: number): string {
149 const today = measure(inRange(files, dayKey(now), dayKey(now))).total
150 const week = measure(inRange(files, weekStartKey(now))).total
151 return `CC today ${fmt(today)} · week ${fmt(week)}`
152}
153
154const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
155
156export function commandText(files: readonly DayFile[], now: number): string {
157 const row = (label: string, t: Totals): string =>
158 `${label.padEnd(6)} ${fmt(t.total).padStart(7)} you ${fmt(t.you)}, claude ${fmt(t.claude)}`
159 const today = dayKey(now)
160 const week = weekStartKey(now)
161 const lines = [
162 'Claude Code time',
163 row('today', measure(inRange(files, today, today))),
164 row('week', measure(inRange(files, week))),
165 row('month', measure(inRange(files, monthStartKey(now)))),
166 '',
167 'Last 7 days',
168 ]
169 for (const day of lastDayKeys(now, 7)) {
170 lines.push(`${day} ${DAYS[dateOf(day).getDay()]} ${fmt(measure(inRange(files, day, day)).total)}`)
171 }
172 lines.push('', 'This week by repo')
173 const repos = perRepo(inRange(files, week), 5)
174 if (repos.length === 0) lines.push('(nothing yet)')
175 for (const r of repos) lines.push(`${r.repo} ${fmt(r.ms)}`)
176 return lines.join('\n')
177}
178
179// Backfill from session transcripts. A python step prints `HEADLESS`, or `C <cwd>`
180// and one `<epoch_ms> <P|E>` line per entry (P a real user prompt, E anything else).
181export const PY_SCAN = `
182import sys, json
183from datetime import datetime
184cwd = ''
185out = []
186for line in open(sys.argv[1], encoding='utf-8', errors='replace'):
187 if '"sdk-cli"' in line:
188 try:
189 if json.loads(line).get('entrypoint') == 'sdk-cli':
190 print('HEADLESS')
191 sys.exit(0)
192 except Exception:
193 pass
194 if '"timestamp"' not in line:
195 continue
196 try:
197 d = json.loads(line)
198 ts = d.get('timestamp')
199 if not isinstance(ts, str):
200 continue
201 ms = int(datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp() * 1000)
202 except Exception:
203 continue
204 if not cwd and isinstance(d.get('cwd'), str):
205 cwd = d['cwd']
206 p = False
207 if d.get('type') == 'user' and not d.get('isMeta'):
208 c = (d.get('message') or {}).get('content')
209 if isinstance(c, str):
210 p = True
211 elif isinstance(c, list):
212 p = not any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in c)
213 out.append('%d %s' % (ms, 'P' if p else 'E'))
214print('C ' + cwd)
215print('\\n'.join(out))
216`
217
218export type BfEvent = { t: number; prompt: boolean }
219export type BfScan = { headless: boolean; cwd: string; events: BfEvent[] }
220
221export function parseScan(stdout: string): BfScan {
222 const scan: BfScan = { headless: false, cwd: '', events: [] }
223 for (const line of stdout.split('\n')) {
224 if (line === 'HEADLESS') return { headless: true, cwd: '', events: [] }
225 if (line.startsWith('C ')) {
226 scan.cwd = line.slice(2).trim()
227 continue
228 }
229 const m = /^(\d+) ([PE])$/.exec(line)
230 if (m) scan.events.push({ t: Number(m[1]), prompt: m[2] === 'P' })
231 }
232 return scan
233}
234
235// Claude working time from entry timestamps: a step into a non-prompt entry no longer than
236// TOOL_CAP counts. A prompt starts a wait, a longer gap is a wait.
237export function transcriptSpans(events: readonly BfEvent[]): Span[] {
238 const sorted = [...events].sort((a, b) => a.t - b.t)
239 const out: Span[] = []
240 for (let i = 1; i < sorted.length; i++) {
241 const a = sorted[i - 1] as BfEvent
242 const b = sorted[i] as BfEvent
243 if (!b.prompt && b.t - a.t <= TOOL_CAP && b.t > a.t) out.push({ s: a.t, e: b.t })
244 }
245 return mergeGap(out, CLAUDE_GAP)
246}
247
248// The intervals of a transcript grouped by work day.
249export function backfillDays(events: readonly BfEvent[]): Map<string, Interval[]> {
250 const days = new Map<string, Interval[]>()
251 for (const span of transcriptSpans(events)) {
252 for (const piece of splitAtWorkday({ ...span, k: 'claude' })) {
253 const day = dayKey(piece.s)
254 days.set(day, [...(days.get(day) ?? []), piece])
255 }
256 }
257 for (const [day, list] of days) days.set(day, compact(list))
258 return days
259}
260
261// Session id of a transcript path: the file name, or `<parent session>-<agent file>` for a subagent.
262export function transcriptId(path: string): string {
263 const parts = path.split('/')
264 const file = (parts.pop() ?? '').replace(/\.jsonl$/, '')
265 return parts[parts.length - 1] === 'subagents' ? `${parts[parts.length - 2]}-${file}` : file
266}
267
268export type Transcript = { path: string; mtimeMs: number }
269
270// Transcripts changed since the last pass.
271export const changedTranscripts = (all: readonly Transcript[], seen: Readonly<Record<string, number>>): Transcript[] =>
272 all.filter(x => x.mtimeMs > (seen[x.path] ?? 0))
273