SLOPSHOPPER

timekeeper

Clock times on messages, a live band for long-running tool calls, and a toast when a long turn ends.

newbandrowsguardtoasttimer
v0.2.0no licenseupdated 2026-10-06laszloprekop/claude-code-timekeeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · timekeeper
› fix the failing auth test and add an audit log call ╭─────────────────────────────╮ │ timekeeper │ ⏺ Read(src/auth.ts) │ Finished 08:53:20, took 42s │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

timekeeper

A mod for Claude Code that shows when things happened and how long they are taking.

  • Timestamps. Each prompt ends with ▶ 14:32:07, and each block of a reply ends with its time on a line of its own. The block that closes the turn is marked ■ 14:32:41. Times are in the local time zone.
  • Progress band. A tool call that has run for 3 seconds or more gets a row above the prompt, for example 1m 12s Bash Run the tests (since 14:32:07). It updates every second and disappears when the call ends.
  • Background tasks. A call that leaves its work running in the background (a background shell command, a background agent, a monitor, a workflow) keeps its row, marked background, after the turn ends. The row counts on until the task's notification arrives, then a notice says how the task ended and how long it ran. clear background rows in the band drops rows whose notification never came.
  • Toast. A turn that takes 30 seconds or longer ends with a notice such as Finished 14:35:10, took 2m 4s.

Install

At the prompt of a Claude Code terminal session:

/plugin install timekeeper --marketplace laszloprekop/claude-code-timekeeper

Answer y to add the marketplace, then choose a scope.

Limits

  • Messages stored before the mod loaded have no timestamp.
  • The band shows elapsed time, not a percentage.
  • A background task is followed from the call that started it. One started before the mod loaded is not shown.
  • The mod API is early access and may change between Claude Code releases. Built against 2.1.292.

Develop

claude --plugin-dir .        # run the mod from this folder
claude plugin validate .     # check the manifest and the hooks module
claude plugin test .         # run tests/

The two thresholds (SHOW_AFTER_MS, TOAST_AFTER_MS) are constants at the top of hooks/register.tsx.

Source 3 files
hooks/register.tsx 268 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RunningTool } from '../types'
5import {
6  backgroundIdOf,
7  formatClock,
8  formatElapsed,
9  labelOf,
10  localOffsetMinutes,
11  parseNotification,
12  textKey,
13} from './format'
14import type { TaskNotification } from './format'
15
16// A tool call shows in the band once it has run this long.
17const SHOW_AFTER_MS = 3_000
18// A turn at least this long ends with a toast.
19const TOAST_AFTER_MS = 30_000
20
21const STAMPS = { plugin: 'timekeeper', key: 'stamps' } as const
22const stamps = atom(STAMPS, null)
23const running = atom({ plugin: 'timekeeper', key: 'running' } as const, [])
24const now = atom({ plugin: 'timekeeper', key: 'now' } as const, 0)
25const lastRow = atom({ plugin: 'timekeeper', key: 'lastRow' } as const, null)
26
27const clock = (ms: number) => formatClock(ms, localOffsetMinutes(ms))
28// Where a turn's closing block is marked: under its row id and its text's key.
29const finalKey = (id: string) => `final:${id}`
30
31// Marks the main loop's latest reply block as the one that closed its turn.
32async function markFinal($: EngineInterface) {
33  const row = await read($, lastRow)
34
35  if (row !== null) {
36    const at = await $.clock.now()
37    await $.state.set({ ...STAMPS, id: finalKey(row.uuid) }, at)
38    await $.state.set({ ...STAMPS, id: finalKey(row.key) }, at)
39    await update($, lastRow, () => null)
40  }
41}
42
43// Takes a background task that ended out of the band and says how it ended.
44async function settle($: EngineInterface, note: TaskNotification) {
45  const ended = (await read($, running)).find(
46    t => t.isBackground && (t.id === note.taskId || t.callId === note.toolUseId),
47  )
48
49  if (ended === undefined) {
50    return
51  }
52
53  await update($, running, list => list.filter(t => t.id !== ended.id))
54
55  const at = await $.clock.now()
56  const what = ended.label === '' ? ended.tool : `${ended.tool} ${ended.label}`
57  $.ui.toast(`Background ${what}: ${note.status} after ${formatElapsed(at - ended.startedAt)}`, {
58    timeoutMs: 8000,
59  })
60}
61
62export const register: Register = on => {
63  on('session.start', async ($, e, next) => {
64    // Foreground calls left over from before a reload are no longer tracked;
65    // background tasks run on, and their notifications still end them.
66    await update($, running, list => list.filter(t => t.isBackground === true))
67    // A reload lands when a turn ends, and may be what saw that turn's end.
68    await markFinal($).catch(() => {})
69
70    $.clock.every(1000, async () => {
71      if ((await read($, running)).length > 0) {
72        const at = await $.clock.now()
73        await update($, now, () => at)
74      }
75    })
76
77    return next(e)
78  })
79
80  // Messages carry no time of their own: note when each row is stored, under
81  // its id and under its text.
82  on('session.append', async ($, e, next) => {
83    try {
84      const { type, isMeta, content } = e.message
85      const isMessage = (type === 'user' || type === 'assistant') && isMeta !== true
86      const texts = content.flatMap(block =>
87        block.type === 'text' && typeof block.text === 'string' ? [block.text] : [],
88      )
89
90      // A background task's end reaches the conversation as a notification row.
91      if (type !== 'assistant' && e.agentId === undefined) {
92        for (const text of texts) {
93          const note = parseNotification(text)
94
95          if (note !== undefined) {
96            await settle($, note)
97          }
98        }
99      }
100
101      if (isMessage && e.agentId === undefined) {
102        if (texts.length > 0) {
103          const at = await $.clock.now()
104          await $.state.set({ ...STAMPS, id: e.uuid }, at)
105
106          for (const text of texts) {
107            await $.state.set({ ...STAMPS, id: textKey(text) }, at)
108          }
109
110          const last = texts.at(-1)
111
112          if (type === 'assistant' && last !== undefined) {
113            await update($, lastRow, () => ({ uuid: e.uuid, key: textKey(last) }))
114          }
115        }
116      }
117    } catch {
118      // A row is stored whether or not its time was noted.
119    }
120
121    return next(e)
122  })
123
124  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
125    const at =
126      (await read($, memberOf(stamps, e))) ??
127      (await $.state.get({ ...STAMPS, id: textKey(e.props.text) })).value
128
129    if (typeof at !== 'number' || e.props.text.trim() === '') {
130      return next(e)
131    }
132
133    return next({ ...e, props: { ...e.props, text: `${e.props.text}  ▶ ${clock(at)}` } })
134  })
135
136  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
137    const at =
138      (await read($, memberOf(stamps, e))) ??
139      (await $.state.get({ ...STAMPS, id: textKey(e.props.text) })).value
140
141    if (typeof at !== 'number' || e.props.text.trim() === '') {
142      return next(e)
143    }
144
145    // The block that closed its turn carries the stop mark.
146    const isFinal =
147      (await $.state.get({ ...STAMPS, id: finalKey(e.requestId) })).value != null ||
148      (await $.state.get({ ...STAMPS, id: finalKey(textKey(e.props.text)) })).value != null
149    const stamp = isFinal ? `■ ${clock(at)}` : clock(at)
150
151    return next({ ...e, props: { ...e.props, text: `${e.props.text}\n\n${stamp}` } })
152  })
153
154  on('tool.call', async ($, e, next) => {
155    try {
156      const startedAt = await $.clock.now()
157      const entry: RunningTool = {
158        id: e.tool_use_id,
159        callId: e.tool_use_id,
160        tool: e.tool,
161        label: labelOf(e),
162        startedAt,
163        isSubagent: e.agentId !== undefined,
164        isBackground: false,
165      }
166
167      await update($, running, list => [...list.filter(t => t.id !== entry.id), entry])
168      await update($, now, () => startedAt)
169    } catch {
170      // The call runs whether or not it is tracked.
171    }
172
173    // A call that handed its work to the background stays in the band under
174    // the task's id, until the task's notification ends it.
175    let taskId: string | undefined
176    let stopped: string | undefined
177
178    try {
179      const ran = await next(e)
180
181      if (ran.deny === undefined && ran.isError !== true) {
182        taskId = backgroundIdOf(e.tool, ran.result)
183        stopped = e.tool === 'TaskStop' ? (e.task_id ?? e.shell_id) : undefined
184      }
185
186      return ran
187    } finally {
188      await update($, running, list =>
189        list.flatMap(t => {
190          if (t.id === stopped) {
191            return []
192          }
193
194          if (t.id !== e.tool_use_id) {
195            return [t]
196          }
197
198          return taskId === undefined ? [] : [{ ...t, id: taskId, isBackground: true }]
199        }),
200      ).catch(() => {})
201    }
202  })
203
204  on('turn.complete', async ($, e, next) => {
205    if (e.agentId === undefined) {
206      try {
207        // The main loop's calls have all settled by now.
208        await update($, running, list => list.filter(t => t.isSubagent || t.isBackground))
209        await markFinal($)
210
211        if (e.durationMs >= TOAST_AFTER_MS && !e.isAborted) {
212          const at = await $.clock.now()
213          $.ui.toast(`Finished ${clock(at)}, took ${formatElapsed(e.durationMs)}`, { timeoutMs: 8000 })
214        }
215      } catch {
216        // The turn ends either way.
217      }
218    }
219
220    return next(e)
221  })
222
223  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224    if (e.props.hasSurvey) {
225      return next(e)
226    }
227
228    const at = await read($, now)
229    const shown = (await read($, running)).filter(
230      t => t.isBackground || at - t.startedAt >= SHOW_AFTER_MS,
231    )
232
233    if (shown.length === 0) {
234      return next(e)
235    }
236
237    const { Box, Button, Text } = $.ui.resolve(e)
238
239    return (
240      <Box flexDirection="column">
241        {shown.map(t => (
242          <Text wrap="truncate-end">
243            <Text color="warning">{formatElapsed(at - t.startedAt)}</Text>
244            <Text bold> {t.tool}</Text>
245            <Text>{t.label === '' ? '' : ` ${t.label}`}</Text>
246            <Text dimColor>
247              {' '}
248              ({t.isBackground ? 'background, ' : ''}since {clock(t.startedAt)}
249              {t.isSubagent ? ', subagent' : ''})
250            </Text>
251          </Text>
252        ))}
253        {shown.some(t => t.isBackground) ? (
254          <Button
255            key="clear-background"
256            label="clear background rows"
257            plain
258            dimColor
259            onPress={() => update($, running, list => list.filter(t => !t.isBackground))}
260          />
261        ) : (
262          ''
263        )}
264      </Box>
265    )
266  })
267}
268
hooks/format.ts 101 lines
1const pad = (n: number) => String(n).padStart(2, '0')
2
3// Minutes east of UTC at that moment, as the environment's own clock has it.
4export const localOffsetMinutes = (ms: number) => -new Date(ms).getTimezoneOffset()
5
6export const formatClock = (ms: number, offsetMinutes: number) => {
7  const d = new Date(ms + offsetMinutes * 60_000)
8
9  return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`
10}
11
12export const formatElapsed = (ms: number) => {
13  const total = Math.max(0, Math.floor(ms / 1000))
14  const h = Math.floor(total / 3600)
15  const m = Math.floor((total % 3600) / 60)
16  const s = total % 60
17
18  if (h > 0) {
19    return `${h}h ${pad(m)}m ${pad(s)}s`
20  }
21
22  return m > 0 ? `${m}m ${pad(s)}s` : `${s}s`
23}
24
25// A short id for a message's text (FNV-1a), for rows whose drawn id is not
26// the id they were stored under.
27export const textKey = (text: string) => {
28  const trimmed = text.trim()
29  let hash = 0x811c9dc5
30
31  for (let i = 0; i < trimmed.length; i += 1) {
32    hash ^= trimmed.charCodeAt(i)
33    hash = Math.imul(hash, 0x01000193)
34  }
35
36  return `text:${trimmed.length}:${(hash >>> 0).toString(16)}`
37}
38
39const LABEL_KEYS = ['description', 'command', 'file_path', 'path', 'pattern', 'url', 'query', 'skill']
40
41export const labelOf = (input: Readonly<Record<string, unknown>>) => {
42  for (const key of LABEL_KEYS) {
43    const value = input[key]
44
45    if (typeof value === 'string' && value.trim() !== '') {
46      const line = value.trim().split('\n')[0] ?? ''
47
48      return line.length > 60 ? `${line.slice(0, 59)}…` : line
49    }
50  }
51
52  return ''
53}
54
55const stringAt = (record: Readonly<Record<string, unknown>>, key: string) => {
56  const value = record[key]
57
58  return typeof value === 'string' && value !== '' ? value : undefined
59}
60
61// The id of the background task a tool call left running, read from the
62// call's result; undefined when the call finished its work itself.
63export const backgroundIdOf = (tool: string, result: unknown) => {
64  if (typeof result !== 'object' || result === null) {
65    return undefined
66  }
67
68  const record = result as Readonly<Record<string, unknown>>
69
70  if (record.status === 'async_launched') {
71    return stringAt(record, 'agentId')
72  }
73
74  if (record.status === 'remote_launched' || tool === 'Monitor' || tool === 'Workflow') {
75    return stringAt(record, 'taskId')
76  }
77
78  return stringAt(record, 'backgroundTaskId')
79}
80
81export type TaskNotification = { taskId?: string; toolUseId?: string; status: string }
82
83const tagOf = (text: string, tag: string) =>
84  new RegExp(`<${tag}>\\s*([^<]*?)\\s*</${tag}>`).exec(text)?.[1] || undefined
85
86// What a background task's notification says of it; undefined for any other
87// text, and for a notification that reports no end (it names no status).
88export const parseNotification = (text: string): TaskNotification | undefined => {
89  if (!text.includes('<task-notification>')) {
90    return undefined
91  }
92
93  const status = tagOf(text, 'status')
94
95  if (status === undefined) {
96    return undefined
97  }
98
99  return { taskId: tagOf(text, 'task-id'), toolUseId: tagOf(text, 'tool-use-id'), status }
100}
101
types/index.d.ts 26 lines
1// The main loop's latest reply block: its row id and its text's key.
2export type LastRow = { uuid: string; key: string }
3
4// `id` is the call's tool_use_id while it runs in the foreground, and the
5// background task's id once the call handed its work to the background.
6export type RunningTool = {
7  id: string
8  callId: string
9  tool: string
10  label: string
11  startedAt: number
12  isSubagent: boolean
13  isBackground: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    timekeeper: {
19      stamps: StateFamily<number | null>
20      running: RunningTool[]
21      now: number
22      lastRow: LastRow | null
23    }
24  }
25}
26