Logs your time, Claude's time and your 5-hour/weekly usage per client, so client work is easy to bill and budget

A Claude Code mod that shows which client is using your time and your usage limits.

For each client it logs:
Everything stays on your machine.
Claude Code with mods (function hooks). Built and tested on Claude Code 2.1.286. Mods are early access, so a later release may change the API this uses.
Clone it and point Claude Code at the folder:
git clone https://github.com/JustinASmith/client-clock ~/.claude/mods/client-clock
Then add this to the env block of ~/.claude/settings.json so every session loads it:
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/client-clock"
To try it in one session first, run claude --plugin-dir ~/.claude/mods/client-clock.
The repo is also a plugin marketplace:
/plugin marketplace add JustinASmith/client-clock
/plugin install client-clock@client-clock
/client acme. Every session in that repo counts toward acme from then on, and earlier sessions there count too. Other clones of the same repo count as acme as well. For your own projects, /client personal logs the time without billing it. In a repo with no label, the band above the prompt asks which client it is./clock: usage bars with each client's points of the current windows, a row per client, Today, Week and Last week, and CSV export./ledger today, /ledger week or /ledger lastweek. /ledger csv week writes a CSV. /ledger add 30m call with acme adds time you spent away from the keyboard./client rate 95. /ledger and the pane then show what your time comes to./ledger timesheet week: one row per day per client with your hours, what they come to, and what you did (your /ledger add notes and commit messages that day). It's written as a CSV, ready for an invoice./client budget 25, in points of your weekly limit. You get a heads-up when the client reaches it, and when one client is at least half of a 5-hour window that's 80% used.The band above the prompt shows today's totals for the repo's client.
Some tools run Claude Code in a folder of their own instead of your client's repo: HyperFrames Studio keeps a folder per project in ~/.hyperframes-studio, and scripts and scheduled tasks run wherever they start. Their time and usage are logged like any session's, but no label covers their folders, so they show as "unassigned". Three ways to place them:
/ledger unassigned week lists the folders behind "unassigned", with their time and points. The pane names the busiest ones too./client acme in ~/.hyperframes-studio/Acme*. * matches any part of a folder name, and a rule covers the folders inside it, including ones the tool makes later. Earlier work in them counts too. /client forget ~/.hyperframes-studio/Acme* removes a rule.CLIENT_CLOCK_CLIENT=acme in its environment, and the whole session counts for acme, whatever its folder.A tool that calls Claude without Claude Code (claude.ai, the mobile app, another machine) isn't logged here. Its usage shows as "other", and /ledger add covers the time.
/clock demo shows every client as "client a", "client b" and so on, and hides folder names, in the band, the pane, /ledger, /client and exports. It's for screenshots and screen shares. Each client keeps its letter. /clock demo off turns it off.
git config user.email) on any local branch of the client's repos, each counted once even across worktrees.A session's client comes from, in order: CLIENT_CLOCK_CLIENT, the label on its repo's remote, the label on its folder, then the longest folder rule that matches.
~/.claude/client-clock/ledger/~/.claude/client-clock/exports/The mod makes no network requests.
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Claude Code writes the API's type declarations into .claude-plugin/types/ when it loads the mod from your folder.
hooks/register.tsx 1454 lines1// client-clock: logs your time, Claude's time and your 5-hour/weekly usage per client.
2//
3// Every session appends its own events to ~/.claude/client-clock/ledger/<session>.jsonl
4// (one file per session, so parallel sessions never write the same file). Reports read
5// the files back and work out, per client:
6// you the gap before each prompt you send, up to IDLE_CAP_MS, plus time a phone
7// client watched Claude work, plus /ledger add entries; overlapping
8// sessions count once, split evenly between the clients active at that moment
9// Claude the wall-clock length of Claude's turns (parallel sessions can overlap)
10// usage each rise in the 5-hour and weekly windows between two readings, split by what
11// each client's sessions spent at API prices in that stretch (the windows are
12// account-wide), or by tokens when no session logged a cost
13// lockout time spent at 100% of a window until it reset, split the same way
14//
15// A session's client comes from, in order: CLIENT_CLOCK_CLIENT in its environment, the
16// label on its repo's remote, the label on its folder, then the longest folder rule that
17// matches it (for tools that run Claude Code in folders of their own, like HyperFrames Studio).
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type {
22 ClientClockBand,
23 ClientClockDash,
24 ClientClockPeriod,
25 ClientClockRow,
26 ClientClockSplit,
27 ClientClockWindow,
28} from '../types'
29
30type $T = EngineInterface
31type Shown = (label: string) => string
32
33const band = atom({ plugin: 'client-clock', key: 'band' } as const, null)
34const dash = atom({ plugin: 'client-clock', key: 'dash' } as const, null)
35const period = atom({ plugin: 'client-clock', key: 'period' } as const, 'week')
36const PANE = 'client-clock'
37
38const IDLE_CAP_MS = 10 * 60 * 1000
39const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
40const PERSONAL = 'personal'
41const UNASSIGNED = 'unassigned'
42const OTHER = 'other (outside Claude Code)'
43const BEFORE = 'before tracking'
44const NOT_CLIENTS = new Set([PERSONAL, UNASSIGNED, OTHER, BEFORE])
45const HOUR = 3600 * 1000
46const FIVE_MS = 5 * HOUR
47const WEEK_MS = 7 * 24 * HOUR
48const WINDOW_MS: Record<string, number> = { five_hour: FIVE_MS, seven_day: WEEK_MS }
49
50type Limit = { k: string; p: number; r?: string }
51
52export type Rec = {
53 t: number
54 kind: 'start' | 'prompt' | 'turn' | 'limits' | 'attach' | 'detach' | 'manual' | 'end'
55 session?: string
56 root?: string
57 remote?: string
58 pin?: string
59 label?: string | null
60 origin?: string
61 during?: boolean
62 ms?: number
63 agent?: boolean
64 tok?: number
65 cost?: number
66 limits?: Limit[]
67 surface?: string
68 client?: string
69 minutes?: number
70 note?: string
71}
72
73type Totals = {
74 youMs: number
75 manualMs: number
76 agentMs: number
77 prompts: number
78 turns: number
79 tokens: number
80 cost: number
81 fivePts: number
82 weekPts: number
83 lockoutMs: number
84 roots: Set<string>
85}
86
87const emptyTotals = (): Totals => ({
88 youMs: 0,
89 manualMs: 0,
90 agentMs: 0,
91 prompts: 0,
92 turns: 0,
93 tokens: 0,
94 cost: 0,
95 fivePts: 0,
96 weekPts: 0,
97 lockoutMs: 0,
98 roots: new Set(),
99})
100
101// ---------- where things live ----------
102
103type Ctx = { session: string; root: string; remote: string | null; pin: string | null; home: string; dir: string }
104let ctx: Ctx | null = null
105
106async function context($: $T): Promise<Ctx> {
107 if (ctx) return ctx
108 const [session, repo, cwd, home, pin] = await Promise.all([
109 $.session.id(),
110 $.session.repo(),
111 $.session.cwd(),
112 $.env.get('HOME'),
113 $.env.get('CLIENT_CLOCK_CLIENT'),
114 ])
115 ctx = {
116 session,
117 root: repo?.root ?? cwd,
118 remote: remoteKey(repo?.remote),
119 pin: pin?.trim().slice(0, 32) || null,
120 home: home ?? '',
121 dir: `${home ?? '.'}/.claude/client-clock`,
122 }
123 return ctx
124}
125
126// Where a ledger line was written: the repo's root and, when it has one, its remote.
127function where(c: Ctx) {
128 return c.remote ? { root: c.root, remote: c.remote } : { root: c.root }
129}
130
131// One key for every clone of a repo: git@github.com:o/n.git and https://github.com/o/n
132// are both github.com/o/n.
133export function remoteKey(url: string | null | undefined): string | null {
134 if (!url) return null
135 let s = url.trim().replace(/\/+$/, '').replace(/\.git$/, '')
136 const scp = /^[^@/:]+@([^:/]+):(.+)$/.exec(s)
137 if (scp) s = `${scp[1]}/${scp[2]}`
138 else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]+@/, '').replace(/^([^/:]+):\d+\//, '$1/')
139 return s.toLowerCase() || null
140}
141
142// The label map keeps three kinds of key: a repo's remote (so every clone of it counts
143// for the same client), a folder, and a folder rule with * wildcards.
144const remoteSlot = (key: string) => `remote:${key}`
145const ruleSlot = (pattern: string) => `rule:${pattern}`
146
147const rules = new Map<string, RegExp>()
148
149// A rule matches its folder and every folder inside it; * stands for any part of one name.
150function ruleMatches(pattern: string, root: string) {
151 let re = rules.get(pattern)
152 if (!re) {
153 const body = pattern
154 .replace(/\/+$/, '')
155 .split('*')
156 .map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
157 .join('[^/]*')
158 re = new RegExp(`^${body}(/.*)?$`)
159 rules.set(pattern, re)
160 }
161 return re.test(root)
162}
163
164export function labelFor(map: Record<string, string>, root: string | undefined, remote: string | null | undefined) {
165 const byRemote = remote ? map[remoteSlot(remote)] : undefined
166 if (byRemote) return byRemote
167 if (!root) return null
168 const byFolder = map[root]
169 if (byFolder) return byFolder
170 let best: { length: number; label: string } | null = null
171 for (const [key, label] of Object.entries(map)) {
172 if (!key.startsWith('rule:')) continue
173 const pattern = key.slice('rule:'.length)
174 if ((!best || pattern.length > best.length) && ruleMatches(pattern, root)) best = { length: pattern.length, label }
175 }
176 return best?.label ?? null
177}
178
179const clientOf = (map: Record<string, string>, c: Ctx) => c.pin ?? labelFor(map, c.root, c.remote)
180
181const tilde = (path: string, home: string) => (home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path)
182const untilde = (path: string, home: string) => (path === '~' || path.startsWith('~/') ? `${home}${path.slice(1)}` : path)
183const folderName = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
184
185async function mapping($: $T): Promise<Record<string, string>> {
186 return ((await $.store.get('clients')) ?? {}) as Record<string, string>
187}
188
189async function budgets($: $T): Promise<Record<string, number>> {
190 return ((await $.store.get('budgets')) ?? {}) as Record<string, number>
191}
192
193async function rates($: $T): Promise<Record<string, number>> {
194 return ((await $.store.get('rates')) ?? {}) as Record<string, number>
195}
196
197// Labels this session's folder, and its remote when it has one.
198async function setLabel($: $T, label: string) {
199 const c = await context($)
200 await $.store.set('clients', {
201 ...(await mapping($)),
202 [c.root]: label,
203 ...(c.remote ? { [remoteSlot(c.remote)]: label } : {}),
204 })
205}
206
207// ---------- the ledger ----------
208
209// Appends one event to this session's ledger file, one write at a time. The mod's API
210// has no append, so the file is rewritten from a copy kept here.
211let chain: Promise<unknown> = Promise.resolve()
212let own: { path: string; text: string } | null = null
213
214function append($: $T, rec: Rec): Promise<unknown> {
215 chain = chain
216 .then(async () => {
217 const c = await context($)
218 const path = `${c.dir}/ledger/${c.session}.jsonl`
219 const before = own?.path === path ? own.text : (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
220 own = { path, text: `${before}${JSON.stringify(rec)}\n` }
221 await $.fs.write(path, own.text)
222 })
223 .catch(err => $.ui.log(`client-clock: could not write the ledger (${String(err).slice(0, 80)})`))
224 return chain
225}
226
227// Ledger files already read, kept until they change.
228const parsed = new Map<string, { mtimeMs: number; size: number; recs: Rec[] }>()
229
230async function loadSince($: $T, since: number): Promise<Rec[]> {
231 const c = await context($)
232 const dir = `${c.dir}/ledger`
233 if (!(await $.fs.exists(dir))) return []
234 const out: Rec[] = []
235 for (const f of await $.fs.list(dir)) {
236 if (f.kind !== 'file' || !f.name.endsWith('.jsonl') || f.mtimeMs < since) continue
237 let hit = parsed.get(f.name)
238 if (!hit || hit.mtimeMs !== f.mtimeMs || hit.size !== f.size) {
239 const session = f.name.slice(0, -'.jsonl'.length)
240 const recs: Rec[] = []
241 for (const line of String(await $.fs.read(`${dir}/${f.name}`)).split('\n')) {
242 if (!line) continue
243 try {
244 recs.push({ ...(JSON.parse(line) as Rec), session })
245 } catch {
246 // a line cut short by a crash: skip it
247 }
248 }
249 hit = { mtimeMs: f.mtimeMs, size: f.size, recs }
250 parsed.set(f.name, hit)
251 }
252 for (const r of hit.recs) out.push(r)
253 }
254 return out.sort((a, b) => a.t - b.t)
255}
256
257// When the clock started: the earliest line in the ledger, worked out once and kept.
258async function trackedSince($: $T): Promise<number> {
259 const kept = await $.store.get('since')
260 if (typeof kept === 'number') return kept
261 const c = await context($)
262 const dir = `${c.dir}/ledger`
263 let first = await $.clock.now()
264 if (await $.fs.exists(dir)) {
265 for (const f of await $.fs.list(dir)) {
266 if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue
267 const line = String(await $.fs.read(`${dir}/${f.name}`)).split('\n', 1)[0] ?? ''
268 try {
269 const t = (JSON.parse(line) as Rec).t
270 if (typeof t === 'number' && t < first) first = t
271 } catch {
272 // an empty or broken file
273 }
274 }
275 }
276 await $.store.set('since', first)
277 return first
278}
279
280// ---------- the arithmetic ----------
281
282type CostPoint = { t: number; usd: number; label: string }
283
284export function summarize(
285 recs: Rec[],
286 map: Record<string, string>,
287 from: number,
288 to: number,
289 now: number,
290 opts: { since?: number } = {},
291) {
292 const totals = new Map<string, Totals>()
293 const get = (label: string) => {
294 let t = totals.get(label)
295 if (!t) totals.set(label, (t = emptyTotals()))
296 return t
297 }
298 const labelOf = (r: Rec) => labelFor(map, r.root, r.remote) || r.label || UNASSIGNED
299 const inRange = (t: number) => t >= from && t < to
300
301 const bySession = new Map<string, Rec[]>()
302 for (const r of recs) {
303 if (!r.session) continue
304 const list = bySession.get(r.session) ?? []
305 list.push(r)
306 bySession.set(r.session, list)
307 }
308
309 const spans: Array<{ a: number; b: number; label: string }> = []
310 const turns: Array<{ a: number; t: number; tok: number; label: string }> = []
311 const costs: CostPoint[][] = []
312
313 for (const list of bySession.values()) {
314 let last: number | null = null
315 let lastCost: number | null = null // the session's running cost total; null until a baseline
316 let pin: string | null = null // CLIENT_CLOCK_CLIENT, which outranks every label
317 let sessionLabel = UNASSIGNED
318 const watching: Array<{ a: number; b: number }> = []
319 const working: Array<{ a: number; b: number }> = []
320 const attached = new Map<string, number>()
321 // The session's running cost at API prices, as points in time.
322 const series: CostPoint[] = []
323 const mark = (t: number, usd: number | undefined) => {
324 const end = series[series.length - 1]
325 if (typeof usd === 'number' && (!end || t >= end.t)) series.push({ t, usd, label: sessionLabel })
326 }
327 // A session spends nothing while it waits: its cost holds until the next turn starts.
328 const hold = (t: number) => {
329 const end = series[series.length - 1]
330 if (end && t > end.t) series.push({ t, usd: end.usd, label: sessionLabel })
331 }
332 for (const r of list) {
333 if (r.kind === 'start' && r.pin) pin = r.pin
334 if (r.root) sessionLabel = pin ?? labelOf(r)
335 if (r.kind === 'start') {
336 last = r.t
337 if (typeof r.cost === 'number') lastCost = r.cost
338 mark(r.t, r.cost)
339 } else if (r.kind === 'prompt') {
340 if (!r.during) hold(r.t)
341 if (!HUMAN_ORIGINS.has(r.origin ?? '')) continue
342 const label = pin ?? labelOf(r)
343 const gap = last === null ? 0 : Math.min(r.t - last, IDLE_CAP_MS)
344 if (gap > 0) spans.push({ a: r.t - gap, b: r.t, label })
345 last = r.t
346 if (inRange(r.t)) get(label).prompts += 1
347 } else if (r.kind === 'turn') {
348 const label = pin ?? labelOf(r)
349 // cost is the session's running total: spend is the rise since the last reading,
350 // and a first reading with no baseline (a session already running) counts as 0
351 const cost = typeof r.cost === 'number' ? r.cost : null
352 const spent = cost !== null && lastCost !== null ? Math.max(0, cost - lastCost) : 0
353 if (cost !== null) lastCost = cost
354 if (!r.agent) hold(r.t - (r.ms ?? 0))
355 mark(r.t, r.cost)
356 turns.push({ a: r.t - (r.ms ?? 0), t: r.t, tok: r.tok ?? 0, label })
357 if (!r.agent) {
358 last = r.t
359 working.push({ a: r.t - (r.ms ?? 0), b: r.t })
360 }
361 if (!inRange(r.t)) continue
362 const tot = get(label)
363 tot.tokens += r.tok ?? 0
364 tot.cost += spent
365 if (r.root) tot.roots.add(r.root)
366 if (!r.agent) {
367 tot.agentMs += r.ms ?? 0
368 tot.turns += 1
369 }
370 } else if (r.kind === 'limits') {
371 mark(r.t, r.cost)
372 } else if (r.kind === 'attach') {
373 // A phone checking in counts; the desktop app and IDEs stay attached to any open session.
374 if (r.client && r.surface === 'mobile') attached.set(r.client, r.t)
375 } else if (r.kind === 'detach') {
376 const since = r.client ? attached.get(r.client) : undefined
377 if (r.client && since !== undefined) {
378 watching.push({ a: since, b: r.t })
379 attached.delete(r.client)
380 }
381 } else if (r.kind === 'manual') {
382 if (inRange(r.t)) get(r.label || sessionLabel).manualMs += (r.minutes ?? 0) * 60 * 1000
383 }
384 }
385 if (series.length > 1) costs.push(series)
386 for (const since of attached.values()) watching.push({ a: since, b: now })
387 // A phone counts as you only while Claude was working.
388 for (const w of watching) {
389 for (const k of working) {
390 const a = Math.max(w.a, k.a)
391 const b = Math.min(w.b, k.b)
392 if (b > a) spans.push({ a, b, label: sessionLabel })
393 }
394 }
395 }
396
397 // Your time, unioned: a moment counts once, split evenly between the clients active then.
398 const edges: Array<{ t: number; d: number; label: string }> = []
399 for (const s of spans) {
400 const a = Math.max(s.a, from)
401 const b = Math.min(s.b, to)
402 if (b > a) edges.push({ t: a, d: 1, label: s.label }, { t: b, d: -1, label: s.label })
403 }
404 edges.sort((x, y) => x.t - y.t || x.d - y.d)
405 const active = new Map<string, number>()
406 let prevT = 0
407 for (const edge of edges) {
408 if (active.size > 0 && edge.t > prevT) {
409 const share = (edge.t - prevT) / active.size
410 for (const label of active.keys()) get(label).youMs += share
411 }
412 const n = (active.get(edge.label) ?? 0) + edge.d
413 if (n > 0) active.set(edge.label, n)
414 else active.delete(edge.label)
415 prevT = edge.t
416 }
417
418 // Usage: split each rise between two readings by what each client's sessions spent in
419 // that stretch. Without costs, by tokens, each turn's spread over the time it ran.
420 turns.sort((x, y) => x.t - y.t)
421 const weight = (x: { a: number; t: number; tok: number }, a: number, b: number) =>
422 x.t > x.a ? (x.tok * Math.max(0, Math.min(x.t, b) - Math.max(x.a, a))) / (x.t - x.a) : x.t > a && x.t <= b ? x.tok : 0
423 const previous = new Map<string, { t: number; p: number; r?: string }>()
424 const shares = new Map<string, Map<string, number>>()
425 const lockouts = new Map<string, { a: number; b: number }>()
426 for (const r of recs) {
427 if (r.kind !== 'limits') continue
428 for (const l of r.limits ?? []) {
429 const span = WINDOW_MS[l.k]
430 if (!span) continue
431 const id = `${l.k}@${l.r ?? '?'}`
432 const before = previous.get(l.k)
433 const isSameWindow = before !== undefined && before.r === l.r
434 const startT = isSameWindow ? before.t : l.r ? Date.parse(l.r) - span : r.t
435 const rise = isSameWindow ? l.p - before.p : l.p
436 previous.set(l.k, { t: r.t, p: l.p, r: l.r })
437 if (l.p >= 100 && l.r && !lockouts.has(id)) lockouts.set(id, { a: r.t, b: Date.parse(l.r) })
438 if (rise <= 0 || !inRange(r.t)) continue
439 const split = shares.get(id) ?? new Map<string, number>()
440 shares.set(id, split)
441 // A window that opened before the clock started holds usage it never saw.
442 if (!isSameWindow && opts.since !== undefined && startT < opts.since) {
443 split.set(BEFORE, (split.get(BEFORE) ?? 0) + rise)
444 continue
445 }
446 const by = new Map<string, number>()
447 for (const series of costs) spend(series, startT, r.t, by)
448 if (sumOf(by) === 0) {
449 for (const x of turns) {
450 const w = weight(x, startT, r.t)
451 if (w > 0) by.set(x.label, (by.get(x.label) ?? 0) + w)
452 }
453 }
454 const sum = sumOf(by)
455 if (sum === 0) split.set(OTHER, (split.get(OTHER) ?? 0) + rise)
456 else for (const [label, w] of by) split.set(label, (split.get(label) ?? 0) + (rise * w) / sum)
457 }
458 }
459 for (const [id, split] of shares) {
460 const isWeek = id.startsWith('seven_day')
461 const total = [...split.values()].reduce((n, v) => n + v, 0)
462 const lock = lockouts.get(id)
463 for (const [label, pts] of split) {
464 const tot = get(label)
465 if (isWeek) tot.weekPts += pts
466 else tot.fivePts += pts
467 if (lock && total > 0) {
468 const locked = Math.max(0, Math.min(lock.b, to, now) - Math.max(lock.a, from))
469 tot.lockoutMs += (locked * pts) / total
470 }
471 }
472 }
473 return totals
474}
475
476// What one session spent between a and b: each rise in its running cost is spread evenly
477// over the stretch between the two points around it.
478function spend(series: CostPoint[], a: number, b: number, into: Map<string, number>) {
479 const first = series[0]
480 const end = series[series.length - 1]
481 if (!first || !end || end.t <= a || first.t > b) return
482 for (let i = 1; i < series.length; i++) {
483 const p = series[i - 1]
484 const q = series[i]
485 if (!p || !q || q.t <= a) continue
486 if (p.t >= b) break
487 const rise = q.usd - p.usd
488 if (rise <= 0) continue
489 const part = q.t > p.t ? (Math.min(q.t, b) - Math.max(p.t, a)) / (q.t - p.t) : 1
490 if (part > 0) into.set(q.label, (into.get(q.label) ?? 0) + rise * part)
491 }
492}
493
494const sumOf = (m: Map<string, number>) => [...m.values()].reduce((n, v) => n + v, 0)
495
496// Each client's points of one window, largest first.
497function splitOf(totals: Map<string, Totals>, key: 'fivePts' | 'weekPts', shown: Shown): ClientClockSplit {
498 return [...totals]
499 .map(([label, t]) => ({ label: shown(label), pts: t[key] }))
500 .filter(x => x.pts >= 0.5)
501 .sort((x, y) => y.pts - x.pts)
502}
503
504// ---------- time helpers ----------
505
506function startOfDay(ms: number) {
507 const d = new Date(ms)
508 d.setHours(0, 0, 0, 0)
509 return d.getTime()
510}
511
512function startOfWeek(ms: number) {
513 const d = new Date(startOfDay(ms))
514 d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
515 return d.getTime()
516}
517
518function minusDays(ms: number, days: number) {
519 const d = new Date(ms)
520 d.setDate(d.getDate() - days)
521 return d.getTime()
522}
523
524function formatMs(ms: number) {
525 const m = Math.round(ms / 60000)
526 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
527}
528
529function hours(ms: number) {
530 return (ms / HOUR).toFixed(2)
531}
532
533function money(usd: number) {
534 return `$${usd.toFixed(usd < 100 ? 2 : 0)}`
535}
536
537// Quoted for CSV. A leading =, +, - or @ gets a ' in front, so a spreadsheet shows the
538// value as text instead of running it as a formula (a commit message could start with one).
539export function csvText(s: string) {
540 const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s
541 return `"${safe.replace(/"/g, '""')}"`
542}
543
544function clockTime(iso?: string) {
545 if (!iso) return 'soon'
546 try {
547 return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
548 } catch {
549 return iso
550 }
551}
552
553function day(ms: number) {
554 try {
555 return new Date(ms).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
556 } catch {
557 return new Date(ms).toISOString().slice(0, 10)
558 }
559}
560
561function localDate(ms: number) {
562 const d = new Date(ms)
563 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
564}
565
566// ---------- demo mode ----------
567
568// In demo mode every client shows as "client a", "client b", ... in the band, the pane,
569// /ledger, /client and exports, and folder names are hidden, so the clock can go in a
570// screenshot or on a shared screen. Each client keeps its letter.
571async function shower($: $T, labels: Iterable<string> = []): Promise<Shown> {
572 if ((await $.store.get('demo')) !== true) return label => label
573 const aliases = { ...(((await $.store.get('aliases')) ?? {}) as Record<string, string>) }
574 const count = Object.keys(aliases).length
575 const known = [...Object.values(await mapping($)), ...Object.keys(await budgets($)), ...Object.keys(await rates($))]
576 for (const label of [...new Set([...known, ...labels])].sort()) {
577 if (!NOT_CLIENTS.has(label) && !aliases[label]) aliases[label] = aliasAt(Object.keys(aliases).length)
578 }
579 if (Object.keys(aliases).length !== count) await $.store.set('aliases', aliases)
580 return label => (NOT_CLIENTS.has(label) ? label : aliases[label] ?? 'client ?')
581}
582
583function aliasAt(i: number) {
584 return i < 26 ? `client ${String.fromCharCode(97 + i)}` : `client ${i + 1}`
585}
586
587async function isDemo($: $T) {
588 return (await $.store.get('demo')) === true
589}
590
591// ---------- the band ----------
592
593let refreshing = false
594
595async function refreshBand($: $T) {
596 if (refreshing) return
597 refreshing = true
598 try {
599 const c = await context($)
600 const now = await $.clock.now()
601 const map = await mapping($)
602 const label = clientOf(map, c)
603 const usage = await $.session.usage()
604 const five = usage.rateLimits.find(l => l.kind === 'five_hour')
605 const week = usage.rateLimits.find(l => l.kind === 'seven_day')
606 const dayFrom = startOfDay(now)
607 const weekFrom = week?.resetsAt ? Date.parse(week.resetsAt) - WEEK_MS : startOfWeek(now)
608 const fiveFrom = five?.resetsAt ? Date.parse(five.resetsAt) - FIVE_MS : now - FIVE_MS
609 const since = await trackedSince($)
610 const recs = await loadSince($, Math.min(dayFrom, weekFrom, fiveFrom))
611 const today = summarize(recs, map, dayFrom, now + 1, now, { since })
612 const thisWeek = summarize(recs, map, weekFrom, now + 1, now, { since })
613 const thisFive = summarize(recs, map, fiveFrom, now + 1, now, { since })
614 const budget = label ? (await budgets($))[label] ?? null : null
615 const known = [...new Set(Object.values(map))].filter(l => l !== PERSONAL).sort()
616 const shown = await shower($, label ? [label, ...known] : known)
617 const value: ClientClockBand = {
618 label,
619 shown: label ? shown(label) : null,
620 known: known.map(l => ({ label: l, shown: shown(l) })),
621 youMs: label ? (today.get(label)?.youMs ?? 0) + (today.get(label)?.manualMs ?? 0) : 0,
622 agentMs: label ? today.get(label)?.agentMs ?? 0 : 0,
623 five: five?.percentUsed ?? null,
624 week: week?.percentUsed ?? null,
625 labelWeek: label ? thisWeek.get(label)?.weekPts ?? 0 : 0,
626 budget,
627 }
628 await update($, band, () => value)
629
630 if (!label || label === PERSONAL) return
631 const warnings: Array<[string, string]> = []
632 if (budget !== null && value.labelWeek >= budget) {
633 warnings.push([
634 `budget:${label}:${week?.resetsAt ?? weekFrom}`,
635 `${shown(label)} has used about ${Math.round(value.labelWeek)} points of your weekly limit (budget ${budget}).`,
636 ])
637 }
638 const fiveAll = [...thisFive.values()].reduce((n, t) => n + t.fivePts, 0)
639 const fiveMine = thisFive.get(label)?.fivePts ?? 0
640 if (five && five.percentUsed >= 80 && fiveAll > 0 && fiveMine / fiveAll >= 0.5) {
641 warnings.push([
642 `five:${label}:${five.resetsAt ?? fiveFrom}`,
643 `${shown(label)} is ${Math.round((100 * fiveMine) / fiveAll)}% of this 5-hour window (${Math.round(five.percentUsed)}% used, resets ${clockTime(five.resetsAt)}).`,
644 ])
645 }
646 if (warnings.length === 0) return
647 const warned = ((await $.store.get('warned')) ?? []) as string[]
648 const fresh = warnings.filter(([key]) => !warned.includes(key))
649 for (const [, text] of fresh) $.ui.toast(text)
650 if (fresh.length) await $.store.set('warned', [...warned, ...fresh.map(([key]) => key)].slice(-100))
651 } catch (err) {
652 $.ui.log(`client-clock: ${String(err).slice(0, 120)}`)
653 } finally {
654 refreshing = false
655 }
656}
657
658async function assign($: $T, label: string) {
659 await setLabel($, label)
660 const shown = await shower($, [label])
661 $.ui.toast(label === PERSONAL ? 'This repo is personal: not billed.' : `This repo now counts as ${shown(label)}.`)
662 await refreshBand($)
663}
664
665// ---------- the report ----------
666
667function periodOf(what: string, now: number) {
668 const week = startOfWeek(now)
669 if (what === 'today') return { from: startOfDay(now), to: now + 1, title: 'today' }
670 if (what === 'week') return { from: week, to: now + 1, title: 'this week' }
671 if (what === 'lastweek') return { from: minusDays(week, 7), to: week, title: 'last week' }
672 return null
673}
674
675// Your commits on any local branch of these repos, so work on a branch or in a worktree
676// counts; worktrees of one repo share their commits, so each counts once.
677async function commitLog($: $T, roots: Set<string>, from: number, to: number) {
678 const seen = new Map<string, { t: number; subject: string }>()
679 for (const root of roots) {
680 const email = await $.process.run(['git', '-C', root, 'config', 'user.email'], { timeoutMs: 5000 }).catch(() => null)
681 const author = email && email.exitCode === 0 ? email.stdout.trim() : ''
682 const run = await $.process
683 .run(
684 [
685 'git', '-C', root, 'log', '--branches', '--no-merges', '--pretty=%H%x09%ct%x09%s',
686 ...(author ? ['--fixed-strings', `--author=${author}`] : []),
687 `--since=${new Date(from).toISOString()}`, `--until=${new Date(to).toISOString()}`,
688 ],
689 { timeoutMs: 10000 },
690 )
691 .catch(() => null)
692 if (!run || run.exitCode !== 0) continue
693 for (const line of run.stdout.split('\n')) {
694 const [hash, ct, ...subject] = line.split('\t')
695 if (hash && !seen.has(hash)) seen.set(hash, { t: Number(ct) * 1000, subject: subject.join('\t') })
696 }
697 }
698 return [...seen].map(([hash, c]) => ({ hash, ...c })).sort((x, y) => x.t - y.t)
699}
700
701export async function commitsIn($: $T, roots: Set<string>, from: number, to: number) {
702 return (await commitLog($, roots, from, to)).length
703}
704
705// One row per client for a period: what /ledger prints and the /clock pane draws.
706async function report($: $T, which: string, now: number) {
707 const span = periodOf(which, now)
708 if (!span) return null
709 const map = await mapping($)
710 const b = await budgets($)
711 const rate = await rates($)
712 const recs = await loadSince($, minusDays(span.from, 7))
713 const totals = summarize(recs, map, span.from, span.to, now, { since: await trackedSince($) })
714 const rows: ClientClockRow[] = []
715 for (const [label, t] of totals) {
716 if (t.youMs + t.manualMs + t.agentMs + t.fivePts + t.weekPts <= 0) continue
717 const youMs = t.youMs + t.manualMs
718 const perHour = rate[label] ?? null
719 rows.push({
720 label,
721 youMs,
722 addedMs: t.manualMs,
723 agentMs: t.agentMs,
724 prompts: t.prompts,
725 turns: t.turns,
726 fivePts: t.fivePts,
727 weekPts: t.weekPts,
728 cost: t.cost,
729 lockoutMs: t.lockoutMs,
730 commits: await commitsIn($, t.roots, span.from, span.to),
731 budget: b[label] ?? null,
732 rate: perHour,
733 billable: perHour === null ? null : (perHour * youMs) / HOUR,
734 })
735 }
736 rows.sort((x, y) => y.youMs + y.agentMs - (x.youMs + x.agentMs))
737 return { span, rows }
738}
739
740// The folders behind "unassigned" in a period, so each can be given a client: tools that
741// run Claude Code in folders of their own land here until a folder rule covers them.
742async function unassignedIn($: $T, span: { from: number; to: number }, now: number) {
743 const map = await mapping($)
744 const recs = await loadSince($, minusDays(span.from, 7))
745 const pinned = new Set(recs.filter(r => r.kind === 'start' && r.pin).map(r => r.session))
746 const probe = { ...map }
747 const sdk = new Set<string>()
748 for (const r of recs) {
749 if (!r.root || pinned.has(r.session) || labelFor(map, r.root, r.remote) !== null) continue
750 probe[r.root] = `?${r.root}`
751 if (r.kind === 'prompt' && r.origin === 'sdk') sdk.add(r.root)
752 }
753 const totals = summarize(recs, probe, span.from, span.to, now, { since: await trackedSince($) })
754 return [...totals]
755 .filter(([label, t]) => label.startsWith('?') && t.youMs + t.manualMs + t.agentMs + t.fivePts + t.weekPts > 0)
756 .map(([label, t]) => ({
757 root: label.slice(1),
758 youMs: t.youMs + t.manualMs,
759 agentMs: t.agentMs,
760 fivePts: t.fivePts,
761 weekPts: t.weekPts,
762 sdk: sdk.has(label.slice(1)),
763 }))
764 .sort((x, y) => y.youMs + y.agentMs - (x.youMs + x.agentMs))
765}
766
767async function exportCsv($: $T, span: { from: number; to: number }, rows: ClientClockRow[], shown: Shown) {
768 const c = await context($)
769 const path = `${c.dir}/exports/ledger-${localDate(span.from)}-to-${localDate(span.to - 1)}.csv`
770 const header =
771 'client,you_hours,added_hours,claude_hours,prompts,turns,five_hour_points,weekly_points,api_equivalent_usd,lockout_hours,commits,rate,billable'
772 const lines = rows.map(r =>
773 [
774 csvText(shown(r.label)),
775 hours(r.youMs - r.addedMs),
776 hours(r.addedMs),
777 hours(r.agentMs),
778 r.prompts,
779 r.turns,
780 r.fivePts.toFixed(1),
781 r.weekPts.toFixed(1),
782 r.cost.toFixed(2),
783 hours(r.lockoutMs),
784 r.commits,
785 r.rate ?? '',
786 r.billable === null ? '' : r.billable.toFixed(2),
787 ].join(','),
788 )
789 await $.fs.write(path, `${header}\n${lines.join('\n')}\n`)
790 return path
791}
792
793// One row per day per client, ready for an invoice: your hours, what they come to at the
794// client's rate, and what you did (your /ledger add notes and commit messages that day).
795async function timesheet($: $T, which: string, now: number) {
796 const span = periodOf(which, now)
797 if (!span) return null
798 const c = await context($)
799 const map = await mapping($)
800 const rate = await rates($)
801 const since = await trackedSince($)
802 const recs = await loadSince($, minusDays(span.from, 7))
803 const whole = summarize(recs, map, span.from, span.to, now, { since })
804 const commits = new Map<string, Array<{ t: number; subject: string }>>()
805 for (const [label, t] of whole) {
806 if (NOT_CLIENTS.has(label) && label !== UNASSIGNED) continue
807 const roots = new Set(t.roots)
808 for (const [key, l] of Object.entries(map)) if (l === label && key.startsWith('/')) roots.add(key)
809 commits.set(label, await commitLog($, roots, span.from, span.to))
810 }
811 const rows: Array<{ date: string; label: string; youMs: number; agentMs: number; rate: number | null; billable: number | null; commits: number; what: string }> = []
812 for (let d = span.from; d < Math.min(span.to, now + 1); d = minusDays(d, -1)) {
813 const end = Math.min(minusDays(d, -1), span.to)
814 for (const [label, t] of summarize(recs, map, d, end, now, { since })) {
815 const youMs = t.youMs + t.manualMs
816 if (label === OTHER || label === BEFORE || youMs + t.agentMs <= 0) continue
817 const notes = recs.filter(r => r.kind === 'manual' && r.label === label && r.note && r.t >= d && r.t < end).map(r => r.note ?? '')
818 const done = (commits.get(label) ?? []).filter(x => x.t >= d && x.t < end).map(x => x.subject)
819 const perHour = rate[label] ?? null
820 rows.push({
821 date: localDate(d),
822 label,
823 youMs,
824 agentMs: t.agentMs,
825 rate: perHour,
826 billable: perHour === null ? null : (perHour * youMs) / HOUR,
827 commits: done.length,
828 what: [...notes, ...done].join('; '),
829 })
830 }
831 }
832 const shown = await shower($, rows.map(r => r.label))
833 const path = `${c.dir}/exports/timesheet-${localDate(span.from)}-to-${localDate(Math.min(span.to, now + 1) - 1)}.csv`
834 const header = 'date,client,you_hours,claude_hours,rate,billable,commits,description'
835 const lines = rows.map(r =>
836 [
837 r.date,
838 csvText(shown(r.label)),
839 hours(r.youMs),
840 hours(r.agentMs),
841 r.rate ?? '',
842 r.billable === null ? '' : r.billable.toFixed(2),
843 r.commits,
844 csvText(r.what),
845 ].join(','),
846 )
847 if (rows.length) await $.fs.write(path, `${header}\n${lines.join('\n')}\n`)
848 return { span, rows, path, shown }
849}
850
851let dashOpen = false
852let dashRefreshing = false
853
854async function refreshDash($: $T) {
855 if (dashRefreshing) return
856 dashRefreshing = true
857 try {
858 const now = await $.clock.now()
859 const which: ClientClockPeriod = await read($, period)
860 const r = await report($, which, now)
861 if (!r) return
862 const c = await context($)
863 const map = await mapping($)
864 const usage = await $.session.usage()
865 const since = await trackedSince($)
866 const loose = await unassignedIn($, r.span, now)
867 const demo = await isDemo($)
868 const shown = await shower($, r.rows.map(row => row.label))
869 // The bars' own windows, with each client's points of them.
870 const windowOf = async (kind: string, span: number): Promise<ClientClockWindow | null> => {
871 const l = usage.rateLimits.find(x => x.kind === kind)
872 if (!l) return null
873 const start = l.resetsAt ? Date.parse(l.resetsAt) - span : now - span
874 const totals = summarize(await loadSince($, start), map, start, now + 1, now, { since })
875 return { pct: l.percentUsed, resetsAt: l.resetsAt ?? null, split: splitOf(totals, kind === 'five_hour' ? 'fivePts' : 'weekPts', shown) }
876 }
877 const value: ClientClockDash = {
878 period: which,
879 title: r.span.title,
880 from: r.span.from,
881 to: r.span.to,
882 five: await windowOf('five_hour', FIVE_MS),
883 week: await windowOf('seven_day', WEEK_MS),
884 rows: r.rows.map(row => ({ ...row, label: shown(row.label) })),
885 loose: loose.slice(0, 3).map((x, i) => ({ name: demo ? `folder ${i + 1}` : folderName(tilde(x.root, c.home)), ms: x.youMs + x.agentMs })),
886 looseCount: loose.length,
887 demo,
888 updatedAt: now,
889 }
890 await update($, dash, () => value)
891 } catch (err) {
892 $.ui.log(`client-clock: ${String(err).slice(0, 120)}`)
893 } finally {
894 dashRefreshing = false
895 }
896}
897
898function resetLabel(iso: string, now: number) {
899 const at = Date.parse(iso)
900 try {
901 const time = new Date(at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
902 if (at - now < 20 * HOUR) return time
903 return `${new Date(at).toLocaleDateString([], { weekday: 'short' })} ${time}`
904 } catch {
905 return iso
906 }
907}
908
909// A markdown table: the first column left, the rest right.
910function table(head: string[], rows: Array<Array<string | number>>) {
911 return [
912 `| ${head.join(' | ')} |`,
913 `|${head.map((_, i) => (i === 0 ? '---' : '---:')).join('|')}|`,
914 ...rows.map(row => `| ${row.join(' | ')} |`),
915 ]
916}
917
918// ---------- hooks ----------
919
920let timer: { cancel: () => void } | undefined
921
922export const register: Register = on => {
923 on('session.start', async ($, e, next) => {
924 ctx = null
925 own = null
926 const c = await context($)
927 const map = await mapping($)
928 const usage = await $.session.usage()
929 await append($, {
930 t: await $.clock.now(),
931 kind: 'start',
932 ...where(c),
933 ...(c.pin ? { pin: c.pin } : {}),
934 label: clientOf(map, c),
935 cost: usage.cost?.usd,
936 })
937 await $.command.register({
938 name: 'client',
939 description: "Client clock: set or show this repo's client",
940 argumentHint: '[name | personal | name in <folder> | budget <points> | rate <per hour> | forget <folder>]',
941 })
942 await $.command.register({
943 name: 'ledger',
944 description: 'Client clock: your time, Claude time and usage per client',
945 argumentHint: '[today | week | lastweek | csv | timesheet | unassigned | add 30m note]',
946 })
947 await $.command.register({ name: 'clock', description: 'Client clock: open the dashboard', argumentHint: '[demo [on | off]]' })
948 dashOpen = (await read($, dash)) !== null
949 if (usage.rateLimits.length) {
950 await append($, {
951 t: await $.clock.now(),
952 kind: 'limits',
953 limits: usage.rateLimits.map(l => ({ k: l.kind, p: l.percentUsed, r: l.resetsAt })),
954 cost: usage.cost?.usd,
955 })
956 }
957 timer?.cancel()
958 timer = $.clock.every(60000, () => {
959 void refreshBand($)
960 if (dashOpen) void refreshDash($)
961 })
962 void refreshBand($)
963 if (dashOpen) void refreshDash($)
964 return next(e)
965 })
966
967 on('prompt.submit', async ($, e, next) => {
968 const c = await context($)
969 void append($, {
970 t: await $.clock.now(),
971 kind: 'prompt',
972 ...where(c),
973 origin: e.origin.kind,
974 during: Boolean(e.turnId),
975 })
976 return next(e)
977 })
978
979 on('turn.complete', async ($, e, next) => {
980 const c = await context($)
981 const usage = await $.session.usage()
982 const u = e.usage as unknown as Record<string, number> | undefined
983 const tok = u
984 ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + 0.1 * (u.cache_read_input_tokens ?? 0)
985 : 0
986 void append($, {
987 t: await $.clock.now(),
988 kind: 'turn',
989 ...where(c),
990 ms: e.durationMs,
991 agent: e.agentId !== undefined,
992 tok: Math.round(tok),
993 cost: usage.cost?.usd,
994 })
995 void refreshBand($)
996 if (dashOpen) void refreshDash($)
997 return next(e)
998 })
999
1000 // Readings of the usage windows. The engine measures when a window moves a whole point,
1001 // mid-turn too, so each reading carries the session's running cost: a turn still running
1002 // counts for its client.
1003 on('session.measure', async ($, e, next) => {
1004 if (e.changed.includes('rateLimits') && e.rateLimits.length) {
1005 void append($, {
1006 t: await $.clock.now(),
1007 kind: 'limits',
1008 limits: e.rateLimits.map(l => ({ k: l.kind, p: l.percentUsed, r: l.resetsAt })),
1009 cost: e.cost?.usd,
1010 })
1011 void refreshBand($)
1012 }
1013 return next(e)
1014 })
1015
1016 on('session.attach', async ($, e, next) => {
1017 void append($, { t: await $.clock.now(), kind: 'attach', surface: e.surface, client: e.clientId })
1018 return next(e)
1019 })
1020
1021 on('session.detach', async ($, e, next) => {
1022 void append($, { t: await $.clock.now(), kind: 'detach', surface: e.surface, client: e.clientId })
1023 return next(e)
1024 })
1025
1026 on('session.end', async ($, e, next) => {
1027 await append($, { t: await $.clock.now(), kind: 'end' })
1028 return next(e)
1029 })
1030
1031 on('command.run', { command: 'client' }, async ($, e) => {
1032 const c = await context($)
1033 const map = await mapping($)
1034 const args = e.args.trim()
1035 const refresh = () => {
1036 void refreshBand($)
1037 if (dashOpen) void refreshDash($)
1038 }
1039 if (!args) {
1040 const b = await budgets($)
1041 const rate = await rates($)
1042 const label = clientOf(map, c)
1043 const known = [...new Set(Object.values(map))].sort()
1044 const shown = await shower($, known)
1045 const demo = await isDemo($)
1046 const ruleKeys = Object.keys(map).filter(key => key.startsWith('rule:'))
1047 const about = (l: string) => {
1048 const notes = [b[l] ? `budget ${b[l]} pts/week` : '', rate[l] ? `$${rate[l]}/hour` : ''].filter(Boolean)
1049 return notes.length ? `${shown(l)} (${notes.join(', ')})` : shown(l)
1050 }
1051 return {
1052 text: [
1053 c.pin
1054 ? `This session counts as ${shown(c.pin)}: CLIENT_CLOCK_CLIENT is set.`
1055 : `This repo${demo ? '' : ` (${tilde(c.root, c.home)})`} is ${label ? shown(label) : 'not assigned to a client yet'}.`,
1056 known.length ? `Clients so far: ${known.map(about).join(', ')}.` : '',
1057 ruleKeys.length
1058 ? demo
1059 ? `Folder rules: ${ruleKeys.length} (hidden in demo mode).`
1060 : `Folder rules: ${ruleKeys.map(key => `${tilde(key.slice('rule:'.length), c.home)} → ${map[key]}`).join(', ')}.`
1061 : '',
1062 'Set it with /client <name>, /client personal, /client <name> in <folder>, /client budget <points of your weekly limit>, or /client rate <per hour>.',
1063 ]
1064 .filter(Boolean)
1065 .join('\n'),
1066 }
1067 }
1068 const [first, second] = args.split(/\s+/)
1069 const label = clientOf(map, c)
1070 if (first === 'budget' || first === 'rate') {
1071 if (!label || label === PERSONAL) return { text: 'Assign this repo to a client first: /client <name>.' }
1072 const shown = await shower($, [label])
1073 if (first === 'budget') {
1074 const points = Number(second)
1075 if (!Number.isFinite(points) || points <= 0 || points > 100) {
1076 return { text: 'Give the budget in points of your weekly limit, 1 to 100: /client budget 25' }
1077 }
1078 await $.store.set('budgets', { ...(await budgets($)), [label]: points })
1079 refresh()
1080 return { text: `${shown(label)}: budget of ${points} points of your weekly limit. You'll get a heads-up when it's reached.` }
1081 }
1082 const all = await rates($)
1083 if (second === 'off' || second === '0') {
1084 const { [label]: _dropped, ...rest } = all
1085 await $.store.set('rates', rest)
1086 refresh()
1087 return { text: `${shown(label)}: no hourly rate.` }
1088 }
1089 const amount = Number((second ?? '').replace(/^\$/, ''))
1090 if (!Number.isFinite(amount) || amount <= 0) return { text: 'Give your hourly rate for this client: /client rate 95' }
1091 await $.store.set('rates', { ...all, [label]: amount })
1092 refresh()
1093 return { text: `${shown(label)}: $${amount}/hour. /ledger and the timesheet show what your time comes to.` }
1094 }
1095 if (first === 'forget') {
1096 const target = untilde(args.slice('forget'.length).trim(), c.home)
1097 if (!(target in map) && !(ruleSlot(target) in map)) return { text: `No label or rule for ${tilde(target, c.home)}.` }
1098 const { [target]: _folder, [ruleSlot(target)]: _rule, ...rest } = map
1099 await $.store.set('clients', rest)
1100 refresh()
1101 return { text: `Forgot ${tilde(target, c.home)}.` }
1102 }
1103 // A folder of a tool's (HyperFrames Studio, a script, a scheduled task): /client acme in
1104 // ~/.hyperframes-studio/Acme*. The rule covers folders inside it, and later ones it matches.
1105 const at = args.lastIndexOf(' in ')
1106 if (at > 0) {
1107 const name = args.slice(0, at).trim().slice(0, 32)
1108 const target = untilde(args.slice(at + ' in '.length).trim(), c.home).replace(/\/+$/, '')
1109 if (!name || !target.startsWith('/')) return { text: 'Give a folder: /client acme in ~/.hyperframes-studio/Acme*' }
1110 await $.store.set('clients', {
1111 ...map,
1112 [ruleSlot(target)]: name,
1113 ...(target.includes('*') ? {} : { [target]: name }),
1114 })
1115 refresh()
1116 const shown = await shower($, [name])
1117 return {
1118 text: `${tilde(target, c.home)} now counts as ${shown(name)}, and so does earlier work there${target.includes('*') ? ' and in any folder the pattern matches' : ''}.`,
1119 }
1120 }
1121 const name = args.slice(0, 32)
1122 await setLabel($, name)
1123 refresh()
1124 const shown = await shower($, [name])
1125 return {
1126 text: [
1127 name === PERSONAL
1128 ? 'This repo is personal: logged, not billed.'
1129 : `This repo now counts as ${shown(name)}. Earlier work in it counts as ${shown(name)} too.`,
1130 c.pin ? `This session still counts as ${shown(c.pin)}: CLIENT_CLOCK_CLIENT is set.` : '',
1131 ]
1132 .filter(Boolean)
1133 .join('\n'),
1134 }
1135 })
1136
1137 on('command.run', { command: 'ledger' }, async ($, e) => {
1138 const c = await context($)
1139 const now = await $.clock.now()
1140 const args = e.args.trim()
1141
1142 if (/^add\b/i.test(args)) {
1143 const m = /^add\s+(\d+(?:\.\d+)?)\s*(m|min|mins|h|hr|hrs)?\b\s*(.*)$/i.exec(args)
1144 if (!m) return { text: 'Add time with /ledger add 30m what you did (or 1.5h).' }
1145 const minutes = Number(m[1]) * (m[2] && m[2].toLowerCase().startsWith('h') ? 60 : 1)
1146 const label = clientOf(await mapping($), c)
1147 if (!label || label === PERSONAL) return { text: 'Assign this repo to a client first: /client <name>.' }
1148 await append($, { t: now, kind: 'manual', ...where(c), label, minutes, note: m[3] || undefined })
1149 void refreshBand($)
1150 const shown = await shower($, [label])
1151 return { text: `Added ${formatMs(minutes * 60000)} to ${shown(label)}${m[3] ? `: ${m[3]}` : ''}.` }
1152 }
1153
1154 const words = args.split(/\s+/).filter(Boolean)
1155 const sub = words[0] === 'csv' || words[0] === 'timesheet' || words[0] === 'unassigned' ? words[0] : null
1156 const which = (sub ? words[1] : words[0]) ?? 'week'
1157 const usage = 'Try /ledger today, /ledger week, /ledger lastweek, /ledger csv week, /ledger timesheet week, /ledger unassigned, or /ledger add 30m note.'
1158
1159 if (sub === 'unassigned') {
1160 const span = periodOf(which, now)
1161 if (!span) return { text: usage }
1162 const loose = await unassignedIn($, span, now)
1163 if (loose.length === 0) return { text: `Nothing unassigned ${span.title}.` }
1164 const demo = await isDemo($)
1165 return {
1166 text: [
1167 `**Unassigned ${span.title}**: folders Claude Code ran in that no client covers`,
1168 '',
1169 ...table(
1170 ['folder', 'you', 'Claude', '5-hour pts', 'weekly pts'],
1171 loose.map((x, i) => [
1172 `${demo ? `folder ${i + 1}` : tilde(x.root, c.home)}${x.sdk ? ' (run by a tool)' : ''}`,
1173 formatMs(x.youMs),
1174 formatMs(x.agentMs),
1175 x.fivePts ? x.fivePts.toFixed(0) : '',
1176 x.weekPts ? x.weekPts.toFixed(1) : '',
1177 ]),
1178 ),
1179 '',
1180 demo
1181 ? 'Folder names are hidden in demo mode: /clock demo off shows them.'
1182 : 'Give one a client with /client <name> in <folder>. A pattern covers the folders a tool makes later too: /client acme in ~/.hyperframes-studio/Acme*',
1183 ].join('\n'),
1184 }
1185 }
1186
1187 if (sub === 'timesheet') {
1188 const sheet = await timesheet($, which, now)
1189 if (!sheet) return { text: usage }
1190 if (sheet.rows.length === 0) return { text: `Nothing logged ${sheet.span.title} yet.` }
1191 const billing = sheet.rows.some(r => r.billable !== null)
1192 return {
1193 text: [
1194 `**Timesheet, ${sheet.span.title}**`,
1195 '',
1196 ...table(
1197 ['date', 'client', 'you', 'Claude', ...(billing ? ['billable'] : []), 'what'],
1198 sheet.rows.map(r => [
1199 r.date,
1200 sheet.shown(r.label),types/index.d.ts 76 lines1/** What the band above the prompt shows for this session's repo. */
2export type ClientClockBand = {
3 /** The repo's client label; null while the repo has none yet. */
4 label: string | null
5 /** The label as shown: "client a" and so on in demo mode. */
6 shown: string | null
7 /** Labels already used in other repos, offered as one-press choices. */
8 known: Array<{ label: string; shown: string }>
9 /** Your time today on this client, in ms. */
10 youMs: number
11 /** Claude's working time today on this client, in ms (turns can overlap). */
12 agentMs: number
13 /** Latest 5-hour and weekly window readings, in percent; null before the first. */
14 five: number | null
15 week: number | null
16 /** This client's estimated share of the current weekly window, in points. */
17 labelWeek: number
18 /** This client's weekly budget in points, when one is set. */
19 budget: number | null
20}
21
22/** Which stretch of time the dashboard shows. */
23export type ClientClockPeriod = 'today' | 'week' | 'lastweek'
24
25/** One client's line on the dashboard and in /ledger. */
26export type ClientClockRow = {
27 label: string
28 /** Your time in ms, including /ledger add entries. */
29 youMs: number
30 /** The /ledger add part of youMs, in ms. */
31 addedMs: number
32 agentMs: number
33 prompts: number
34 turns: number
35 fivePts: number
36 weekPts: number
37 /** Estimated cost at API prices, in USD. */
38 cost: number
39 lockoutMs: number
40 commits: number
41 /** Weekly budget in points, when set. */
42 budget: number | null
43 /** Your hourly rate for this client, when set. */
44 rate: number | null
45 /** Your time at that rate. */
46 billable: number | null
47}
48
49/** Each client's points of one usage window, largest first. */
50export type ClientClockSplit = Array<{ label: string; pts: number }>
51
52/** A usage window as the dashboard shows it. */
53export type ClientClockWindow = { pct: number; resetsAt: string | null; split: ClientClockSplit }
54
55/** Everything the /clock pane draws. */
56export type ClientClockDash = {
57 period: ClientClockPeriod
58 title: string
59 from: number
60 to: number
61 five: ClientClockWindow | null
62 week: ClientClockWindow | null
63 rows: ClientClockRow[]
64 /** The busiest folders no client covers (tools that run in folders of their own). */
65 loose: Array<{ name: string; ms: number }>
66 looseCount: number
67 demo: boolean
68 updatedAt: number
69}
70
71declare module 'claude-code' {
72 interface PluginState {
73 'client-clock': { band: ClientClockBand | null; dash: ClientClockDash | null; period: ClientClockPeriod }
74 }
75}
76