SLOPSHOPPER

active-time

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

newguardcommandstatuspromptprocess
v0.1.0MITupdated 2026-10-020xGondarxyz/claude-code-mods/active-time
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · active-time
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /claude-time ⎿ active-time: Claude Code time ⎿ active-time: today 0m you 0m, claude 0m ⎿ active-time: week 0m you 0m, claude 0m ⎿ active-time: month 0m you 0m, claude 0m ⎿ active-time: ⎿ active-time: Last 7 days ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ active-time: CC today 0m · week 0m
README

active-time

A Claude Code mod that counts the time you really spend with Claude Code. An open terminal does not count. A dev server running in the background does not count.

The mod lives in a folder named active-time because Claude Code reserves plugin names that start with claude-. The command is still /claude-time.

Install

/plugin marketplace add 0xGondarxyz/claude-code-mods
/plugin install active-time@claude-code-mods

Or try it without installing:

git clone https://github.com/0xGondarxyz/claude-code-mods
claude --plugin-dir claude-code-mods/active-time

Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.

What counts

Two kinds of active time:

  • you: you write in the prompt box. Edits with a gap of 120 s or less form one interval. A longer pause starts a new one. Submitting a prompt closes the interval.
  • claude: Claude works. This is each model request and each tool call of any agent of any agent (one tool call counts at most 10 minutes). Background subagents count while they work. Gaps of 30 s or less between these join. The wait of a turn (for example on a question) does not count, only the model requests and tool calls inside it.

The total is the union of both kinds. Overlaps count once. Two sessions that work at the same time count once.

Status line

CC today 2h14m · week 11h05m

A day is a work day from 05:00 to 05:00 local time, so work after midnight counts for the day before. The week starts on Monday 05:00. It updates after each turn and every 60 s.

Command

/claude-time shows:

  • today, this week and this month, each split into you and claude
  • the last 7 days, one line each
  • this week per repo (top 5, by folder name)

Where the data lives

Each session writes its own file, so sessions never overwrite each other:

~/.claude/active-time/<YYYY-MM-DD>_<sessionId>.json

Content: { "repo": "...", "intervals": [{ "s": ms, "e": ms, "k": "you" | "claude" }] }. A session that crosses 05:00 writes one file per work day, and <YYYY-MM-DD> is the work day. The repo is the git top level of the session folder, or the folder itself.

To reset, delete the folder:

rm -r ~/.claude/active-time

Backfill from transcripts

Sessions that started before the mod loaded are rebuilt from the transcripts in ~/.claude/projects, for the last 40 days. This gives Claude time only. Typing time of old sessions cannot be recovered.

  • Each transcript entry has a timestamp. For two entries in a row, the time between them counts as claude time when the second one is not a real user prompt and the gap is 10 minutes or less. A prompt starts a wait. A longer gap is a wait too. Gaps of 30 s or less join.
  • Headless transcripts (claude -p, the SDK) are skipped.
  • Results go to ~/.claude/active-time/<YYYY-MM-DD>_<sessionId>.bf.json (same shape plus "source": "transcript"). Live files are never touched. Totals take the union of all files, so a session with both kinds counts once.
  • It runs in the background at the start of an interactive session, then every 10 minutes. Only transcripts changed since the last run are read again. The first run can take a while.
  • It needs python3 (/usr/bin/python3) and find.

Headless runs

Sessions without a person at the prompt (claude -p, the SDK) record nothing, write no file and show no status line.

Limits

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