SLOPSHOPPER

client-clock

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

newpanebandcommandtoastprompt
v0.1.1MITupdated 2026-10-07JustinASmith/client-clock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · client-clock
│ ┃ Client clock ✕ › fix the failing auth test and add an audit log call │ ┃ CLIENT CLOCK this week │ ┃ ⏺ Read(src/auth.ts) │ ┃ 5-hour ████████░░░░░░░░░░░░░░░░░░ 31% r ⎿ Read 6 lines │ ┃ Week no reading yet ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing logged this week yet. Label a repo ⏺ Bash(bun test) │ ┃ with /client <name>. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Today ] [ Week ] [ Last week ] [ Export CS ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ You = gaps before your prompts (up to 10m) ✻ Worked for 42s · done 4:20 PM │ ┃ plus checking in from your phone. Points │ ┃ split your account-wide limits by what each › /client │ ┃ client's sessions spent, so they're ⎿ client-clock: This repo (/work/app) is not assigned to a client │ ┃ estimates. Updated 8:53 AM; /ledger has the ⎿ client-clock: Set it with /client <name>, /client personal, /cli │ ┃ full table. │ Client clock: which client is this repo? [ personal ] or /client name ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Client clock: which client is this repo? [ personal ] or /client name
Pane · Client clock
CLIENT CLOCK this week 5-hour ████████░░░░░░░░░░░░░░░░░░ 31% resets Invalid Da Week no reading yet Nothing logged this week yet. Label a repo with /client <name>. [ Today ] [ Week ] [ Last week ] [ Export CSV ] You = gaps before your prompts (up to 10m) plus checking in from your phone. Points split your account-wide limits by what each client's sessions spent, so they're estimates. Updated 8:53 AM; /ledger has the full table.
README

client-clock

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

The /clock pane: usage bars and a table of clients

For each client it logs:

  • your time: the gap before each prompt you send, up to 10 minutes, plus time you spend checking in from your phone while Claude works
  • Claude's time: how long each turn ran
  • each client's share of your 5-hour and weekly limits, in percentage points
  • estimated cost at API prices
  • what your time comes to at your rate for that client
  • time locked out when a limit hits 100%
  • your commits on any local branch

Everything stays on your machine.

Requirements

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.

Install

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

Use

  1. Label each repo once. In a client's repo, run /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.
  2. Open the dashboard with /clock: usage bars with each client's points of the current windows, a row per client, Today, Week and Last week, and CSV export.
  3. Print the table with /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.
  4. Set a rate with /client rate 95. /ledger and the pane then show what your time comes to.
  5. Make a timesheet with /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.
  6. Set a budget with /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.

Tools that run Claude Code in their own folders

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.
  • A folder rule labels a tool's folders from any session: /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.
  • A pin: start the tool or script with 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.

Demo mode

/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.

How it counts

  • You: the gap before each prompt you type, capped at 10 minutes so a break doesn't count. Checking in from your phone counts while Claude is working. Having the session open in the desktop app or an IDE doesn't: Claude working in the background, unattended, is Claude's time, not yours. Overlapping sessions count once, split between the clients active at that moment.
  • Claude: each turn's duration. Turns in parallel sessions can overlap, so this can add up to more than the clock time.
  • Points: the 5-hour and weekly limits are account-wide, so each rise between two readings is split by what each client's sessions spent at API prices in between. API prices weigh models and output the way limits roughly do. Every reading carries the session's running cost, so a turn still running counts too. When no session logged a cost, the split falls back to tokens. These are estimates.
  • Other and before tracking: usage it can't tie to any session shows as "other". The usage already in a window when the clock started shows as "before tracking".
  • Cost: what Claude Code reports the session would cost at API prices, counted from when the clock started.
  • Billable: your time at the client's rate.
  • Locked out: time at 100% of a limit, split by each client's share of that window.
  • Commits: your commits (by 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.

Data

  • One JSONL file per session in ~/.claude/client-clock/ledger/
  • CSV exports and timesheets in ~/.claude/client-clock/exports/
  • Labels, rules, budgets, rates and demo mode in the mod's own store

The mod makes no network requests.

Develop

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.

License

MIT

Source 2 files
hooks/register.tsx 1454 lines
1// 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 lines
1/** 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