SLOPSHOPPER

message-timestamps

Prefixes each Claude reply in the transcript with the local time it arrived, with no model calls

newrows
★ 1v0.1.0no licenseupdated 2026-10-01benjaminmodayil/live-recap/plugins/message-timestamps
A shopper browsing a rack in a slop shop
README

message-timestamps

A Claude Code plugin that prefixes each Claude reply block in the transcript with the local time it arrived. No model calls, no tokens: the time is read from the clock when the block is appended and formatted with Intl in your locale and time zone.

⏺ `11:04 AM` I'll add the band hook to register.tsx.

  ⏺ Update(hooks/register.tsx)
    ...

⏺ `11:06 AM` Done. Tests pass.

A block that opens with block markdown (heading, list, code fence, quote, table) gets the time on its own line above it, so the markdown still renders.

Requirements

Function hooks must be on: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (see the root README).

Install

claude plugin marketplace add benjaminmodayil/live-recap
claude plugin install message-timestamps@live-recap

Behavior

  • Display only: the stored message and what the model reads are unchanged (ctrl+o shows the original).
  • Deterministic: a block's time is fixed when it is appended; redraws, scrolls and resizes never change it.
  • Every text block of a reply is stamped, so long turns show progression.
  • Blocks loaded by --resume / --continue were not appended in this session and draw without a time.

Develop

.claude-plugin/types/ is written by Claude Code when it loads the plugin from a folder you own; it is gitignored.

tsc -p .                               # typecheck
claude plugin validate --strict .      # manifest + hooks check
claude plugin test .                   # tests/*.test.ts

The test kit in Claude Code 2.1.285 has no stand-in for session.append or plugin state, so tests cover the formatting (hooks/stamp.ts) and the unstamped render path; the append-to-render wiring was verified by loading the plugin in a live session (2.1.285).

Source 3 files
hooks/register.tsx 33 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { stamp } from './stamp'
5
6const sentAt = atom({ plugin: 'message-timestamps', key: 'sentAt' } as const, 0)
7
8export const register: Register = on => {
9  // The append is the one moment a reply block is new, so its time is fixed there and never recomputed on redraw.
10  on('session.append', { door: 'response' }, async ($, e, next) => {
11    const at = await $.clock.now()
12    const stored = await next(e)
13    const hasText = e.message.content.some(block => block.type === 'text')
14
15    if (stored.uuid !== undefined && e.message.type === 'assistant' && hasText) {
16      await update($, memberOf(sentAt, { requestId: stored.uuid }), () => at)
17    }
18
19    return stored
20  })
21
22  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
23    const at = await read($, memberOf(sentAt, e))
24
25    // Rows loaded by a resume were never appended in this session, so they draw unstamped.
26    if (at === 0) {
27      return next(e)
28    }
29
30    return next({ ...e, props: { ...e.props, text: stamp(e.props.text, at) } })
31  })
32}
33
hooks/stamp.ts 20 lines
1// The module's environment carries the host's locale and time zone, so the default formatter is the user's own.
2const formatTime = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
3
4// Markdown that only parses at the start of a line: a stamp ahead of it would turn it into a plain paragraph.
5const BLOCK_START = /^(#{1,6}\s|```|~~~|[-*+]\s|>|\||\d+[.)]\s|(-{3,}|\*{3,}|_{3,})\s*$| {4}|\t)/
6
7/**
8 * Prefixes reply markdown with the local time it arrived, as inline code.
9 *
10 * @param text the reply block's markdown, as the transcript draws it
11 * @param at epoch milliseconds the block arrived
12 * @returns the text led by the time: inline when the text opens with a paragraph, on its own line when it opens with block markdown (heading, list, fence, quote, table, rule, indented code)
13 * @example
14 * stamp('Done.', at) // '`11:04 AM` Done.'
15 */
16export const stamp = (text: string, at: number) => {
17  const label = `\`${formatTime.format(at)}\``
18  return BLOCK_START.test(text) ? `${label}\n\n${text}` : `${label} ${text}`
19}
20
types/index.d.ts 12 lines
1// Epoch ms a reply block was appended; 0 while unknown.
2export type MessageTimestampsSentAt = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'message-timestamps': {
7      // One per reply block, keyed by the row's uuid.
8      sentAt: StateFamily<MessageTimestampsSentAt>
9    }
10  }
11}
12