Times on everything in Claude Code: your messages, replies, tool calls and 'replied 14:07 · took 1m 48s' on each turn. Optional: let Claude know the time too.

See when everything happened. Optionally, let Claude know the time too. 🕑
A Claude Code mod that adds the time to your conversation, the way it would look if Claude Code had it built in.<br> A dim time under every message, reply, tool call and command, replied 14:07 · took 1m 48s on the line that closes each turn, and, if you turn it on, a short timestamp with each prompt so Claude knows what time it is and how long it's been.

1. Use Claude Code as usual. You don't do anything.
2. Everything in the conversation gets a dim time underneath. Your messages, Claude's replies, tool calls (with how long they took), command output, and notifications. 14:05 for today, or Mon 9 Oct, 14:05 for an older day. Prefer it quieter? /timestamps mine keeps times under your own messages only.
3. Each turn's closing line shows when Claude replied and how long it took. Claude Code's own ✻ Baked for 1m 48s becomes ✻ replied 14:07 · took 1m 48s.
4. Optional, off by default: let Claude know the time. Type /timestamps claude on and each prompt you send carries one short timestamp that only Claude reads, so Claude knows the date, the time, your time zone, and how long it's been since you last wrote. It costs about 22 tokens per prompt (see What Claude sees). Your words are never changed.

Claude Code still draws its own rows. The mod only adds a dim line under them, so text wraps exactly as before, in a narrow terminal or a wide one.
❯ fix the flaky login test
14:05
Read 1 file, ran 1 shell command
14:05 · took 3s
● Found it. The test waits on a fixed 200 ms timeout…
14:07
✻ replied 14:07 · took 1m 48s
Claude Code shows no times at all. When you scroll back through a long session, you can't tell what you asked an hour ago from what you asked a minute ago, or which step took twenty minutes. It's one of the most asked-for features on the Claude Code issue tracker (for example #21051 and #69032).
Claude doesn't know the time either. It's told the date when a session starts and nothing after, so in a session that runs past midnight, or one you come back to the next day, it can't tell how long you were away. Timestamps fixes both.
I wanted it to feel like it was always there: no extra boxes, no colours, nothing that moves your text around. Just the time, in the same dim grey Claude Code uses for its own notes.
| Where | What you see | With /timestamps mine |
|---|---|---|
| Your message | 14:05, 2:05 PM or 5m ago. Older than today: Mon 9 Oct, 14:05; another year: Mon 9 Oct 2025, 14:05. | Same |
| Each block of Claude's reply | The time it was written | Nothing |
A tool call, or a collapsed group of calls (Read 2 files, ran 1 shell command) | 14:05 · took 3s: when it started and how long it ran | Nothing |
Command output (/cost, /timestamps, …) | The time it ran | Nothing |
| Notifications and messages from other agents | The time they arrived | Nothing |
| The line that closes a turn | ✻ replied 14:07 · took 1m 48s | Same |
Where nothing is drawn (claude -p, the VS Code chat panel) | One line after each turn: sent 14:05 · replied 14:07 · took 1m 48s | Same |
A row with no recorded time shows nothing, never a guess.
<br>Relative style: /timestamps relative | <br>Quieter: /timestamps mine |
<br>Narrow terminals: nothing moves |
[!IMPORTANT] Off by default. Nothing is sent to Claude unless you turn it on with
/timestamps claude on(or the Tell Claude the time option).Token cost when it's on: about 22 tokens per prompt (about 35 on the first prompt of a session, or after a gap of 10 minutes or more). Twenty prompts cost about 450 tokens. The note goes to Anthropic with your prompt, like everything else you send.
When it's on, Claude reads your words exactly as you typed them, and then one short note, added by the mod. The first prompt of a session:
Timestamp (timestamps plugin, not the user): Mon 2026-10-12 15:13:17 +11:00, Australia/Sydney.
After that, just the time, and how long it's been when it's been a while:
Timestamp (timestamps plugin, not the user): Mon 2026-10-12 17:21:03 +11:00. 3h 16m after the user's previous message.
2026-10-12 with the weekday, the time in 24-hour form with seconds, and the UTC offset.Australia/Sydney) is sent once per session, on the first prompt, and again after the conversation is compacted./timestamps claude on / /timestamps claude off, or the Tell Claude the time option. It's off until you do. The times on screen work either way.1. Install. In Claude Code, type this and press Enter:
/plugin install timestamps --marketplace paragpandyareal/claude-code-timestamps
If it asks to add the marketplace, answer y. If it asks where to install it, choose User (all your projects).
Or, in two steps:
/plugin marketplace add paragpandyareal/claude-code-timestamps
/plugin install timestamps@claude-code-timestamps
2. That's it. It starts working in the session straight away. Send a message and the time appears under it.
Needs Claude Code v2.1.287 or later (run claude --version). Nothing else to install.
If Claude Code says userConfig options not yet set, that's fine: every option has a default (below), and you can change them later.
[!NOTE] Out of the box, Claude isn't told the time: the mod only draws on your screen and adds nothing to what Claude reads. To let Claude know the time, type
/timestamps claude on. That costs about 22 tokens per prompt. See What Claude sees.
| To… | Type |
|---|---|
| Turn times off, or back on | /timestamps |
| Turn them on or off explicitly | /timestamps on, /timestamps off |
14:05 or 2:05 PM | /timestamps 24h, /timestamps 12h |
14:05 or 5m ago | /timestamps clock, /timestamps relative |
| Times on every row, or only under your messages | /timestamps all, /timestamps mine |
| Let Claude know the time (off by default, about 22 tokens per prompt) | /timestamps claude on, /timestamps claude off |
| Go back to your options | /timestamps reset |
Your choice is remembered in every session until you change it. Relative times count up once a minute; older than today, they show the date instead. /timestamps off hides the times on screen. Whether Claude is told the time is separate: /timestamps claude on or off.
Set them when you install, or any time with /plugin configure timestamps@claude-code-timestamps.
| Option | Default | What it does |
|---|---|---|
Hour format (format) | auto | 12h, 24h, or auto to follow your computer's locale |
Style (style) | clock | clock (14:05) or relative (5m ago) |
Where times appear (show) | everything | everything, or mine for your own messages only |
Show turn duration (showDuration) | on | Adds took … to the closing line and to tool calls |
Tell Claude the time (tellClaude) | off | Adds the timestamp note beside each prompt. About 22 tokens per prompt, sent to Anthropic with it. Same as /timestamps claude on. |
Fill in older messages (backfill) | on | On /resume and /branch, reads this session's own transcript to show times for rows from before the mod loaded |
In Claude Code's plugin store on your computer (~/.claude/plugins/store/), one entry per session: the id of each row and its time, the time of your last prompt, and the path of that session's transcript. No message text. It keeps at most 5,000 rows per session, 200 sessions, 90 days and 2 MiB in all, well under the store's 4 MiB limit, and deletes the oldest first.
Times survive /resume, /branch, /clear (the new conversation starts fresh), compaction, restarts and two sessions running at once (each writes only its own entry).
everything, each reply block, tool call and command gets one more line. /timestamps mine goes back to times under your messages only./resume or /branch, or when the mod reloads. If you install it partway through a session, that session's earlier rows show no time until you resume it.timestamps: sent 14:05 · replied 14:07 · took 1m 48s. Plain claude -p output is unchanged, so scripts that read it aren't affected; the line goes into the session's transcript.TZ to change it, for example TZ=Australia/Sydney claude). The note Claude reads uses the same zone.| Problem | What to do |
|---|---|
| No times appear | Run /reload-plugins (or restart Claude Code). Check timestamps is enabled in /plugin. Check /timestamps didn't turn them off: type /timestamps on. |
| Too many times on screen | /timestamps mine |
/timestamps doesn't exist | Your Claude Code is older than v2.1.287. Run claude --version and update. |
| The times are an hour or more out | Claude Code is using your computer's (or server's) time zone. Set TZ, as above. |
| Older rows have no time | See Older rows and Transcripts over 4 MB above. Try /resume on that session. |
| Claude doesn't know the time | It's off by default. Type /timestamps claude on (about 22 tokens per prompt). The note is read with each new prompt. |
| Something else | Open an issue (see Support) with your Claude Code version, operating system and terminal. |
Timestamps is a Claude Code mod: code that runs inside Claude Code with your user account's permissions, outside Claude Code's sandbox. This is everything it does.
| It… | When | Why |
|---|---|---|
| Notes the time each row is added to the conversation: your prompts, Claude's reply blocks, tool calls and results, commands, and the turn's closing line. It reads each row's id, type and who sent it, and the ids of tool calls inside it. Never the words. | As each row is added | So it can show the time |
| Adds the timestamp note beside your prompt, for Claude (about 22 tokens) | Only if you turned it on (/timestamps claude on); off by default | So Claude knows the time |
| Saves ids and times in its own entry in Claude Code's plugin store | Half a second after a change, and when a session ends | So times survive restarts and /resume |
| Reads this session's own transcript file, and only that file | On /resume, /branch and reloads, if Fill in older messages is on | To fill in times for rows from before the mod loaded |
| Draws a dim line under rows and rewrites the turn's closing line | When Claude Code draws them | To show the times |
| Adds one line after each turn | Only where nothing is drawn (claude -p, the VS Code chat panel) | So those users get times too |
| Deletes its own oldest entries | Five seconds after a session starts | To stay under 2 MiB |
It never makes network requests, starts no programs, writes no files, calls no model, reads no environment variables, and adds no tools to Claude. Out of the box it adds nothing to what Claude reads; the timestamp note is sent only if you turn it on. It has no telemetry or accounts.
This section is for anyone reviewing the code, including Anthropic's directory review. Everything that touches Claude Code is in hooks/register.ts. You can check the list yourself: claude plugin validate . in a clone of this repo prints it.
Hooks. None of these blocks, approves or rewrites a tool call. Each passes its event on and returns what Claude Code returns, except where noted. Every hook has a .catch that passes the event on unchanged if anything goes wrong, so a failure shows Claude Code's normal behaviour with no time.
| Hook | What it does with what it sees |
|---|---|
session.start | Registers /timestamps, reads its saved settings and this session's saved times, and (on a reload) fills in from the transcript it already knows |
classic.SessionStart | Reads the session's id and transcript path after startup, /clear, /resume, /branch and compaction. On /resume and /branch, fills in from that transcript. |
session.end | Saves this session's times |
session.append | Notes the time of a row by its id. It reads the row's door, type, id and origin, and (for Claude's replies and tool results) the tool-call ids in it. It never reads or changes the text. |
prompt.submit | Adds one entry to the prompt's context: the timestamp note. It never changes text (your words). Off by default: it does this only after you turn on Tell Claude the time (/timestamps claude on). Skipped for prompts other plugins send. Slash commands don't pass through it. |
turn.complete | For the main conversation only, and only where nothing is drawn: adds sent … · replied … · took … as the line after the answer. Everywhere else it returns the answer unchanged. |
ui.render for UserMessage, AssistantMessage, ToolUse, ToolGroup, CommandOutput | Draws Claude Code's own row with one dim line under it. Rows with no known time, and (with mine) every row but your own messages, are left exactly as Claude Code draws them. An expanded tool group is left alone, since each call in it has its own row. |
ui.render for TurnDuration | Replaces the text of the turn's closing line with ✻ replied … · took … |
command.run for timestamps only | Answers its own /timestamps command. It doesn't see or change other commands. |
Calls.
| Call | What for |
|---|---|
fs.read | Only to read the current session's own transcript file, to fill in times for older rows. The path is the one Claude Code gives for this session, and it's read only if the file is named after this session's id (<session id>.jsonl). From it the mod takes only ids, row types and times. Nothing else is ever read. Turn it off with Fill in older messages. |
store.get, store.set, store.keys, store.delete | Its settings and one entry per session (row ids and times), in its own store |
session.id, session.surfaces | Which session this is, and whether the terminal or the Desktop app is drawing |
clock.after, clock.every | Saving in the background, filling in after the hook has returned, and redrawing: once a minute in relative style, otherwise once at midnight so yesterday's rows show their date |
command.register, ui.resolve, ui.invalidate | Adding /timestamps and drawing |
What it writes. Only its own entries in Claude Code's plugin store. No files.
What it sends to Claude. Nothing, unless you turn on Tell Claude the time. Then only the timestamp note (about 22 tokens), beside each prompt you send, built from the clock, your computer's time zone and the time of your previous prompt. Never anything from a file or another message.
Credentials. It reads none, and no environment variables.
The tests/ folder holds the automated tests, run with claude plugin test. Claude Code never loads them when you use the plugin.
| Likely finding | What the mod does |
|---|---|
Adds context to a prompt (prompt.submit) | Off by default; only after the person turns it on. One short fixed-form line, built only from the clock, the system time zone and the previous prompt's time, labelled as a timestamp from this plugin and not the user's. Never changes the typed text; skipped for other plugins' prompts; off with one option. Shown word for word in the README. |
Reads a file (fs.read) | Only this session's own transcript, by the path Claude Code hands its SessionStart hook, and only when the file's name is <this session id>.jsonl. It reads ids and times from it. Off with one option. |
Hooks an event that can refuse (session.append, classic.SessionStart) | Observes only: notes a time and passes the row on unchanged; its .catch passes it on if anything fails. It never answers in place of next. |
Changes a turn's answer (turn.complete) | Only where nothing is drawn, and only by adding a line under the answer. Claude Code never rewrites the stored answer, and plain claude -p output is unchanged. |
Hooks an event that is also a call (command.run) | Answers only its own /timestamps. |
Rewrites drawing (ui.render) | Keeps Claude Code's own drawing of each row and adds a line; replaces only the text of the turn's closing line. |
Australia/Sydney). It's off by default; /timestamps claude off turns it off again.Full policy: Privacy policy.
/timestamps choice applies everywhere./plugin uninstall timestamps./plugin marketplace remove claude-code-timestamps.timestamps in ~/.claude/plugins/store/.MIT © 2026 Parag Pandya.
hooks/register.ts 402 lines1// Copyright (c) 2026 Parag Pandya. MIT License, see LICENSE.
2// Timestamps for Claude Code: https://github.com/paragpandyareal/claude-code-timestamps
3
4// timestamps: a dim time under each row of the conversation (or only under
5// your own messages), "replied 14:07 · took 1m 48s" on the line that closes
6// each turn, and one plain sentence with each prompt so Claude knows the time.
7//
8// Every hook here records or draws, then hands the event on. The one change
9// to what Claude reads is that sentence, added beside the prompt as context.
10// Any failure falls back to Claude Code's own behaviour.
11
12import type { EngineInterface, PluginOptions, Register, RenderInput, RenderNode } from 'claude-code'
13import { closingLine, localeHourFormat, modelNote, msUntilMidnight, stamp, toolLine, type HourFormat, type Prefs, type Show, type Style } from './format'
14import {
15 PREFS_KEY,
16 SESSION_PREFIX,
17 capRows,
18 pruneKeys,
19 readPrefs,
20 readRecord,
21 sessionKey,
22 type SessionRecord,
23 type StoredPrefs,
24} from './store'
25import { isOwnTranscript, readTranscript } from './transcript'
26
27type Options = {
28 format: HourFormat | 'auto'
29 style: Style
30 showDuration: boolean
31 show: Show
32 tellClaude: boolean
33 backfill: boolean
34}
35
36/** Row id → when it happened, for everything this process knows about. */
37const times = new Map<string, number>()
38/** The rows this session recorded itself, which is what gets stored. */
39let own: Record<string, number> = {}
40let sessionId: string | undefined
41let transcriptPath: string | undefined
42let stored: StoredPrefs = {}
43let opt: Options = { format: 'auto', style: 'clock', showDuration: true, show: 'everything', tellClaude: false, backfill: true }
44let systemFormat: HourFormat = '24h'
45let drawsUI = true
46let lastPromptAt: number | undefined
47/** For Claude's note: when the person last sent a prompt, and Claude last answered. */
48let lastUserAt: number | undefined
49/** The session whose notes have named the time zone already (once per session and after compaction). */
50let zoneSentFor: string | undefined
51let flushTimer: { cancel: () => void } | undefined
52let tickTimer: { cancel: () => void } | undefined
53let backfilledFor: string | undefined
54
55const HELP = 'usage: /timestamps [on|off|12h|24h|clock|relative|all|mine|claude on|claude off|reset]'
56
57function readOptions(o: PluginOptions): Options {
58 return {
59 format: o.format === '12h' || o.format === '24h' ? o.format : 'auto',
60 style: o.style === 'relative' ? 'relative' : 'clock',
61 showDuration: o.showDuration !== false,
62 show: o.show === 'mine' ? 'mine' : 'everything',
63 // Off unless the person turns it on: it adds to what Claude reads.
64 tellClaude: o.tellClaude === true,
65 backfill: o.backfill !== false,
66 }
67}
68
69function show(): Show {
70 return stored.show ?? opt.show
71}
72
73/** Whether Claude is told the time: off unless the person turned it on. */
74function tellClaude(): boolean {
75 return stored.tellClaude ?? opt.tellClaude
76}
77
78function prefs(): Prefs {
79 return {
80 enabled: stored.enabled ?? true,
81 format: stored.format ?? (opt.format === 'auto' ? systemFormat : opt.format),
82 style: stored.style ?? opt.style,
83 showDuration: opt.showDuration,
84 }
85}
86
87// ── storage ──────────────────────────────────────────────────────────────
88
89async function flush($: EngineInterface) {
90 flushTimer?.cancel()
91 flushTimer = undefined
92 if (!sessionId) return
93 const rec: SessionRecord = { u: Date.now(), m: capRows(own) }
94 if (transcriptPath) rec.p = transcriptPath
95 if (lastUserAt !== undefined) rec.lu = lastUserAt
96 own = rec.m
97 await $.store.set(sessionKey(sessionId), rec)
98}
99
100function markDirty($: EngineInterface) {
101 if (flushTimer) return
102 flushTimer = $.clock.after(500, () => flush($).catch(() => {}))
103}
104
105function remember($: EngineInterface, id: string, t: number) {
106 times.set(id, t)
107 own[id] = t
108 markDirty($)
109}
110
111const later = (a: number | undefined, b: number | undefined) => (a === undefined ? b : b === undefined ? a : Math.max(a, b))
112
113async function backfillNow($: EngineInterface, file: string) {
114 try {
115 // Rejects over 4 MiB; then older rows just show no time.
116 const text = await $.fs.read(file)
117 let added = 0
118 const read = readTranscript(text)
119 lastUserAt = later(lastUserAt, read.lastUser)
120 for (const [k, t] of read.times) {
121 if (!times.has(k)) {
122 times.set(k, t)
123 added++
124 }
125 }
126 if (added) $.ui.invalidate('ui.render')
127 } catch {}
128}
129
130/** Switches to session `id`: loads what it stored and, once, its transcript. */
131async function enterSession($: EngineInterface, id: string, path: string | undefined, fill: boolean) {
132 if (id !== sessionId) {
133 if (sessionId && Object.keys(own).length) await flush($).catch(() => {})
134 sessionId = id
135 own = {}
136 transcriptPath = undefined
137 lastUserAt = undefined
138 const rec = readRecord(await $.store.get(sessionKey(id)))
139 if (rec) {
140 own = rec.m
141 transcriptPath = rec.p
142 lastUserAt = rec.lu
143 for (const [k, t] of Object.entries(rec.m)) times.set(k, t)
144 }
145 }
146 if (path && path !== transcriptPath && isOwnTranscript(path, id)) {
147 transcriptPath = path
148 markDirty($)
149 }
150 const file = transcriptPath
151 if (fill && opt.backfill && backfilledFor !== id && isOwnTranscript(file, id)) {
152 backfilledFor = id
153 // Off the hook's path: the prompt never waits on the read.
154 $.clock.after(0, () => backfillNow($, file))
155 }
156 $.ui.invalidate('ui.render')
157}
158
159async function prune($: EngineInterface) {
160 const keys = (await $.store.keys()).filter(k => k.startsWith(SESSION_PREFIX))
161 if (keys.length < 2) return
162 const rows = []
163 for (const key of keys) {
164 const raw = await $.store.get(key)
165 rows.push([key, readRecord(raw), JSON.stringify(raw ?? null).length] as const)
166 }
167 for (const key of pruneKeys(rows, Date.now(), sessionId && sessionKey(sessionId))) await $.store.delete(key)
168}
169
170// ── redraws: once a minute in relative style, else once at midnight ──────
171
172function schedule($: EngineInterface) {
173 tickTimer?.cancel()
174 tickTimer = undefined
175 const p = prefs()
176 if (!p.enabled || !drawsUI) return
177 if (p.style === 'relative') {
178 tickTimer = $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
179 } else {
180 tickTimer = $.clock.after(msUntilMidnight(Date.now()), () => {
181 $.ui.invalidate('ui.render')
182 schedule($)
183 })
184 }
185}
186
187async function refreshSurfaces($: EngineInterface) {
188 const s = await $.session.surfaces()
189 drawsUI = s.includes('terminal') || s.includes('desktop')
190}
191
192// ── drawing ──────────────────────────────────────────────────────────────
193
194/** Claude Code's own row, with one dim line of ours under it. */
195async function withLine($: EngineInterface, e: RenderInput, theirs: RenderNode, text: string) {
196 const { Box, Text } = $.ui.resolve(e)
197 return Box({
198 flexDirection: 'column',
199 children: [theirs, Text({ color: 'inactive', wrap: 'truncate-end', children: [' ' + text] })],
200 })
201}
202
203async function setPrefs($: EngineInterface, arg: string): Promise<string> {
204 const cur = prefs()
205 let next: StoredPrefs
206 if (arg === '') next = { ...stored, enabled: !cur.enabled }
207 else if (arg === 'on' || arg === 'off') next = { ...stored, enabled: arg === 'on' }
208 else if (arg === '12h' || arg === '24h') next = { ...stored, format: arg, enabled: true }
209 else if (arg === 'clock' || arg === 'relative') next = { ...stored, style: arg, enabled: true }
210 else if (arg === 'all' || arg === 'everything') next = { ...stored, show: 'everything', enabled: true }
211 else if (arg === 'mine') next = { ...stored, show: 'mine', enabled: true }
212 else if (arg === 'reset') next = {}
213 else if (arg === 'claude' || arg === 'claude on' || arg === 'claude off') {
214 const on = arg === 'claude' ? !tellClaude() : arg === 'claude on'
215 stored = { ...stored, tellClaude: on }
216 try {
217 await $.store.set(PREFS_KEY, stored)
218 } catch {}
219 return on
220 ? 'Claude is now told the time with each prompt: a short note (about 22 tokens) sent to Anthropic with your prompt. /timestamps claude off to stop.'
221 : 'Claude is no longer told the time. Times on screen are unchanged.'
222 } else return HELP
223 stored = next
224 try {
225 await $.store.set(PREFS_KEY, stored)
226 } catch {}
227 schedule($)
228 $.ui.invalidate('ui.render')
229 const p = prefs()
230 const claude = tellClaude() ? 'Claude told the time' : 'Claude not told the time'
231 return (p.enabled ? `on · ${p.format} · ${p.style} · ${show() === 'mine' ? 'your messages' : 'everything'}` : 'off') + ' · ' + claude
232}
233
234export const register: Register = (on, options) => {
235 opt = readOptions(options)
236 systemFormat = localeHourFormat()
237
238 // ── session lifecycle ────────────────────────────────────────────────
239
240 on('session.start', async ($, e, next) => {
241 try {
242 await $.command.register({
243 name: 'timestamps',
244 description: 'Show or hide times (12h/24h, clock/relative, all/mine), and choose whether Claude is told the time',
245 argumentHint: '[on|off|12h|24h|clock|relative|all|mine|claude on|claude off|reset]',
246 immediate: true,
247 })
248 stored = readPrefs(await $.store.get(PREFS_KEY))
249 drawsUI = e.surface === 'terminal' || e.surface === 'desktop'
250 // On a reload, fill in from the transcript this session already named.
251 await enterSession($, await $.session.id(), undefined, true)
252 schedule($)
253 $.clock.after(5_000, () => prune($).catch(() => {}))
254 } catch {}
255 return next(e)
256 }).catch(($, e, next) => next(e))
257
258 // Fires after startup, /clear, /resume, /branch (fork) and compaction:
259 // the one place that names the session's id and transcript file.
260 on('classic.SessionStart', async ($, e, next) => {
261 try {
262 const fill = e.source === 'resume' || e.source === 'fork'
263 // Compaction drops the earlier notes, so the next one names the zone again.
264 if (e.source === 'compact') zoneSentFor = undefined
265 if (e.session_id) await enterSession($, e.session_id, e.transcript_path, fill)
266 } catch {}
267 return next(e)
268 }).catch(($, e, next) => next(e))
269
270 on('session.end', async ($, e, next) => {
271 try {
272 await flush($)
273 } catch {}
274 return next(e)
275 }).catch(($, e, next) => next(e))
276
277 // ── recording ────────────────────────────────────────────────────────
278
279 on('session.append', ($, e, next) => {
280 try {
281 const now = Date.now()
282 const m = e.message
283 const blocks = Array.isArray(m.content) ? m.content : []
284 if (e.door === 'prompt' && m.type === 'user') {
285 if (!e.agentId) {
286 remember($, e.uuid, now)
287 if (e.origin.kind !== 'task-notification') lastPromptAt = now
288 }
289 } else if (e.door === 'notice' && m.name === 'turn_duration') {
290 if (!e.agentId) remember($, e.uuid, now)
291 } else if (e.door === 'response') {
292 // Each block of Claude's reply, and each tool call by its id.
293 if (!e.agentId) remember($, e.uuid, now)
294 for (const b of blocks) if (b.type === 'tool_use' && typeof b.id === 'string') remember($, b.id, now)
295 } else if (e.door === 'tool-result') {
296 for (const b of blocks) if (b.type === 'tool_result' && typeof b.tool_use_id === 'string') remember($, 'r:' + b.tool_use_id, now)
297 } else if (e.door === 'command' && !e.agentId) {
298 remember($, e.uuid, now)
299 }
300 } catch {}
301 return next(e)
302 }).catch(($, e, next) => next(e))
303
304 // The one thing Claude reads from this mod: a sentence beside the prompt
305 // saying when it was sent. The words you typed are never changed.
306 on('prompt.submit', ($, e, next) => {
307 try {
308 if (!tellClaude() || e.origin.kind === 'plugin') return next(e)
309 const now = Date.now()
310 const note = modelNote(now, lastUserAt, zoneSentFor !== sessionId)
311 zoneSentFor = sessionId
312 if (e.origin.kind === 'composer') lastUserAt = now
313 return next({ ...e, context: [...(e.context ?? []), note] })
314 } catch {
315 return next(e)
316 }
317 }).catch(($, e, next) => next(e))
318
319 // Where nothing draws (the VS Code panel, claude -p), the turn's own
320 // closing line is the only place a time can go.
321 on('turn.complete', async ($, e, next) => {
322 const result = await next(e)
323 try {
324 if (e.agentId) return result
325 await refreshSurfaces($)
326 const p = prefs()
327 if (drawsUI || !p.enabled) return result
328 const now = Date.now()
329 const sent = lastPromptAt !== undefined ? 'sent ' + stamp(lastPromptAt, now, p) + ' · ' : ''
330 return { ...result, text: sent + closingLine(now, e.durationMs, now, p) }
331 } catch {
332 return result
333 }
334 }).catch(($, e, next) => next(e))
335
336 // ── drawing ──────────────────────────────────────────────────────────
337
338 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
339 const p = prefs()
340 if (!p.enabled || (show() === 'mine' && e.props.origin.kind !== 'composer')) return next(e)
341 // The row is drawn once under a placeholder id before it is stored.
342 const t = times.get(e.requestId) ?? (e.requestId === 'placeholder' ? Date.now() : undefined)
343 if (t === undefined) return next(e)
344 return withLine($, e, await next(e), stamp(t, Date.now(), p))
345 }).catch(($, e, next) => next(e))
346
347 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
348 const p = prefs()
349 const t = times.get(e.requestId)
350 if (!p.enabled || t === undefined) return next(e)
351 const { Text } = $.ui.resolve(e)
352 return Text({ color: 'inactive', children: ['✻ ' + closingLine(t, e.props.durationMs, Date.now(), p)] })
353 }).catch(($, e, next) => next(e))
354
355 // ── drawing: everything else, when `show` is everything ──────────────
356
357 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
358 const p = prefs()
359 const t = times.get(e.requestId)
360 if (!p.enabled || show() !== 'everything' || t === undefined) return next(e)
361 return withLine($, e, await next(e), stamp(t, Date.now(), p))
362 }).catch(($, e, next) => next(e))
363
364 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
365 const p = prefs()
366 const t = times.get(e.requestId)
367 if (!p.enabled || show() !== 'everything' || t === undefined) return next(e)
368 return withLine($, e, await next(e), toolLine(t, times.get('r:' + e.requestId), Date.now(), p))
369 }).catch(($, e, next) => next(e))
370
371 // A collapsed run of calls ("Read 2 files, ran 1 shell command"): the first
372 // call's time, and how long the run took. Expanded, each ToolUse has its own.
373 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
374 const p = prefs()
375 if (!p.enabled || show() !== 'everything' || e.props.isExpanded) return next(e)
376 let first: number | undefined
377 let last: number | undefined
378 for (const c of e.props.calls) {
379 if (!c.tool_use_id) continue
380 const a = times.get(c.tool_use_id)
381 const b = times.get('r:' + c.tool_use_id)
382 if (a !== undefined && (first === undefined || a < first)) first = a
383 if (b !== undefined && (last === undefined || b > last)) last = b
384 }
385 if (first === undefined) return next(e)
386 return withLine($, e, await next(e), toolLine(first, last, Date.now(), p))
387 }).catch(($, e, next) => next(e))
388
389 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
390 const p = prefs()
391 const t = times.get(e.requestId)
392 if (!p.enabled || show() !== 'everything' || t === undefined) return next(e)
393 return withLine($, e, await next(e), stamp(t, Date.now(), p))
394 }).catch(($, e, next) => next(e))
395
396 // ── /timestamps ──────────────────────────────────────────────────────
397
398 on('command.run', { command: 'timestamps' }, async ($, e) => ({
399 text: await setPrefs($, e.args.trim().toLowerCase()),
400 }))
401}
402hooks/format.ts 170 lines1// Copyright (c) 2026 Parag Pandya. MIT License, see LICENSE.
2// Timestamps for Claude Code: https://github.com/paragpandyareal/claude-code-timestamps
3
4// Pure time formatting. Every function takes `now` so tests are deterministic
5// and nothing here reaches outside the module.
6
7export type HourFormat = '12h' | '24h'
8export type Style = 'clock' | 'relative'
9export type Show = 'everything' | 'mine'
10
11export type Prefs = {
12 enabled: boolean
13 format: HourFormat
14 style: Style
15 showDuration: boolean
16}
17
18const MINUTE = 60_000
19const HOUR = 60 * MINUTE
20const DAY = 24 * HOUR
21
22/** 12h or 24h, as the system locale writes an hour. 24h if Intl can't say. */
23export function localeHourFormat(): HourFormat {
24 try {
25 const o = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions()
26 if (o.hourCycle) return o.hourCycle === 'h11' || o.hourCycle === 'h12' ? '12h' : '24h'
27 return o.hour12 ? '12h' : '24h'
28 } catch {
29 return '24h'
30 }
31}
32
33const pad = (n: number) => (n < 10 ? '0' + n : String(n))
34
35/** `14:05` or `2:05 PM`. */
36export function clockTime(ts: number, format: HourFormat): string {
37 const d = new Date(ts)
38 const h = d.getHours()
39 const m = pad(d.getMinutes())
40 if (format === '24h') return pad(h) + ':' + m
41 return (h % 12 || 12) + ':' + m + (h < 12 ? ' AM' : ' PM')
42}
43
44const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
45const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
46
47function namePart(d: Date, opt: Intl.DateTimeFormatOptions, fallback: string): string {
48 try {
49 const s = new Intl.DateTimeFormat(undefined, opt).format(d).replace(/\.$/, '')
50 return s || fallback
51 } catch {
52 return fallback
53 }
54}
55
56/** `Mon 9 Oct`, with the year when it isn't this year's. */
57export function dayLabel(ts: number, now: number): string {
58 const d = new Date(ts)
59 const wd = namePart(d, { weekday: 'short' }, WEEKDAYS[d.getDay()] ?? '')
60 const mon = namePart(d, { month: 'short' }, MONTHS[d.getMonth()] ?? '')
61 const year = d.getFullYear() !== new Date(now).getFullYear() ? ' ' + d.getFullYear() : ''
62 return wd + ' ' + d.getDate() + ' ' + mon + year
63}
64
65export function isSameDay(a: number, b: number): boolean {
66 const x = new Date(a)
67 const y = new Date(b)
68 return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate()
69}
70
71/** `just now`, `5m ago`, `3h ago`. Only used for today's messages. */
72export function relativeTime(ts: number, now: number): string {
73 const diff = Math.max(0, now - ts)
74 if (diff < MINUTE) return 'just now'
75 if (diff < HOUR) return Math.floor(diff / MINUTE) + 'm ago'
76 return Math.floor(diff / HOUR) + 'h ago'
77}
78
79/** What a message shows: today's time (or relative), or the date and time. */
80export function stamp(ts: number, now: number, prefs: Pick<Prefs, 'format' | 'style'>): string {
81 if (!isSameDay(ts, now)) return dayLabel(ts, now) + ', ' + clockTime(ts, prefs.format)
82 if (prefs.style === 'relative') return relativeTime(ts, now)
83 return clockTime(ts, prefs.format)
84}
85
86/** `5s`, `1m 48s`, `1h 2m`, rounded down the way the engine's line is. */
87export function duration(ms: number): string {
88 const s = Math.max(0, Math.floor(ms / 1000))
89 if (s < 60) return s + 's'
90 const m = Math.floor(s / 60)
91 if (m < 60) return m + 'm ' + (s % 60) + 's'
92 const h = Math.floor(m / 60)
93 if (h < 24) return h + 'h ' + (m % 60) + 'm'
94 return Math.floor(h / 24) + 'd ' + (h % 24) + 'h'
95}
96
97/** `replied 14:07 · took 1m 48s`, or without the duration. */
98export function closingLine(ts: number, durationMs: number | undefined, now: number, prefs: Prefs): string {
99 const head = 'replied ' + stamp(ts, now, prefs)
100 if (!prefs.showDuration || durationMs === undefined) return head
101 return head + ' · took ' + duration(durationMs)
102}
103
104/** A tool call: `14:05`, or `14:05 · took 12s` once its result is back. */
105export function toolLine(call: number, result: number | undefined, now: number, prefs: Pick<Prefs, 'format' | 'style' | 'showDuration'>): string {
106 const head = stamp(call, now, prefs)
107 if (!prefs.showDuration || result === undefined || result - call < 1000) return head
108 return head + ' · took ' + duration(result - call)
109}
110
111/** Milliseconds until the next local midnight, plus a second of slack. */
112export function msUntilMidnight(now: number): number {
113 const d = new Date(now)
114 const next = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
115 return Math.min(DAY, next - now) + 1000
116}
117
118// ── the note Claude reads with each prompt ───────────────────────────────
119// Kept short on purpose: it rides on every prompt. About 25 tokens.
120
121const SHORT_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
122
123/** `+11:00`, `-04:30`, `+00:00`. */
124export function utcOffset(ts: number): string {
125 const off = -new Date(ts).getTimezoneOffset()
126 const a = Math.abs(off)
127 return (off < 0 ? '-' : '+') + pad(Math.floor(a / 60)) + ':' + pad(a % 60)
128}
129
130/** The IANA zone name (`Australia/Sydney`), or undefined. */
131export function timeZoneName(): string | undefined {
132 try {
133 const z = new Intl.DateTimeFormat().resolvedOptions().timeZone
134 return z && z !== 'UTC' && z !== 'Etc/UTC' ? z : undefined
135 } catch {
136 return undefined
137 }
138}
139
140/** `Mon 2026-10-12 14:05:07 +11:00`: unambiguous in any locale, 24-hour, with its offset. */
141export function isoLocal(ts: number): string {
142 const d = new Date(ts)
143 return (SHORT_WEEKDAYS[d.getDay()] ?? '') + ' ' + d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
144 ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + ' ' + utcOffset(ts)
145}
146
147/** `12m`, `3h 16m`, `2d 4h`. */
148export function gap(ms: number): string {
149 const m = Math.max(0, Math.floor(ms / 60_000))
150 if (m < 60) return m + 'm'
151 const h = Math.floor(m / 60)
152 if (h < 24) return h + 'h ' + (m % 60) + 'm'
153 return Math.floor(h / 24) + 'd ' + (h % 24) + 'h'
154}
155
156/** Below this, Claude can tell the gap from the earlier notes in its context. */
157export const GAP_SHOWN_FROM = 10 * 60_000
158
159/**
160 * The note the model reads beside a prompt, never shown:
161 * `Timestamp (timestamps plugin, not the user): Mon 2026-10-12 17:21:03 +11:00, Australia/Sydney. 3h 16m after the user's previous message.`
162 * The zone name only when `withZone` (the session's first note); the gap only when it's long.
163 */
164export function modelNote(ts: number, previousUser: number | undefined, withZone: boolean, zone = timeZoneName()): string {
165 let s = 'Timestamp (timestamps plugin, not the user): ' + isoLocal(ts)
166 if (withZone && zone) s += ', ' + zone
167 if (previousUser !== undefined && ts - previousUser >= GAP_SHOWN_FROM) s += '. ' + gap(ts - previousUser) + " after the user's previous message"
168 return s + '.'
169}
170hooks/store.ts 87 lines1// Copyright (c) 2026 Parag Pandya. MIT License, see LICENSE.
2// Timestamps for Claude Code: https://github.com/paragpandyareal/claude-code-timestamps
3
4// What the mod keeps in $.store, and how it stays small. Pure: no $ here.
5//
6// prefs what /timestamps last set (overrides the userConfig defaults)
7// s:<session> { u: last write, p: transcript path, lu: last prompt,
8// m: { rowId: epoch ms } }
9//
10// One key per session, so two sessions running at once never write the same key.
11
12import type { HourFormat, Show, Style } from './format'
13
14export const PREFS_KEY = 'prefs'
15export const SESSION_PREFIX = 's:'
16
17export type StoredPrefs = { enabled?: boolean; format?: HourFormat; style?: Style; show?: Show; tellClaude?: boolean }
18
19/** `lu`: the user's last prompt, for the gap in Claude's note. */
20export type SessionRecord = { u: number; p?: string; lu?: number; m: Record<string, number> }
21
22/** Prune limits: well under the store's 4 MiB. */
23export const LIMITS = {
24 maxSessions: 200,
25 maxBytes: 2 * 1024 * 1024,
26 maxAgeMs: 90 * 24 * 60 * 60 * 1000,
27 maxRowsPerSession: 5000,
28}
29
30export const sessionKey = (id: string) => SESSION_PREFIX + id
31
32export function readPrefs(v: unknown): StoredPrefs {
33 if (!v || typeof v !== 'object') return {}
34 const o = v as Record<string, unknown>
35 const out: StoredPrefs = {}
36 if (typeof o.enabled === 'boolean') out.enabled = o.enabled
37 if (o.format === '12h' || o.format === '24h') out.format = o.format
38 if (o.style === 'clock' || o.style === 'relative') out.style = o.style
39 if (o.show === 'everything' || o.show === 'mine') out.show = o.show
40 if (typeof o.tellClaude === 'boolean') out.tellClaude = o.tellClaude
41 return out
42}
43
44export function readRecord(v: unknown): SessionRecord | undefined {
45 if (!v || typeof v !== 'object') return undefined
46 const o = v as Record<string, unknown>
47 if (typeof o.u !== 'number' || !o.m || typeof o.m !== 'object') return undefined
48 const m: Record<string, number> = {}
49 for (const [k, t] of Object.entries(o.m as Record<string, unknown>)) if (typeof t === 'number') m[k] = t
50 const rec: SessionRecord = { u: o.u, p: typeof o.p === 'string' ? o.p : undefined, m }
51 if (typeof o.lu === 'number') rec.lu = o.lu
52 return rec
53}
54
55/** Keeps the newest rows of one session when it passes the per-session cap. */
56export function capRows(m: Record<string, number>, max = LIMITS.maxRowsPerSession): Record<string, number> {
57 if (Object.keys(m).length <= max) return m
58 const out: Record<string, number> = {}
59 for (const [id, t] of Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, max)) out[id] = t
60 return out
61}
62
63/**
64 * Which session keys to delete: anything older than maxAgeMs, then the oldest
65 * past maxSessions or past maxBytes of JSON. `keep` (the live session) stays.
66 */
67export function pruneKeys(
68 records: ReadonlyArray<readonly [key: string, rec: SessionRecord | undefined, bytes: number]>,
69 now: number,
70 keep?: string,
71): string[] {
72 const doomed: string[] = []
73 const live: Array<readonly [string, SessionRecord, number]> = []
74 for (const [key, rec, bytes] of records) {
75 if (key === keep && rec) live.push([key, rec, bytes])
76 else if (!rec || now - rec.u > LIMITS.maxAgeMs) doomed.push(key)
77 else live.push([key, rec, bytes])
78 }
79 live.sort((a, b) => (a[0] === keep ? -1 : b[0] === keep ? 1 : b[1].u - a[1].u))
80 let total = 0
81 live.forEach(([key, , bytes], i) => {
82 total += bytes
83 if (key !== keep && (i >= LIMITS.maxSessions || total > LIMITS.maxBytes)) doomed.push(key)
84 })
85 return doomed
86}
87hooks/transcript.ts 69 lines1// Copyright (c) 2026 Parag Pandya. MIT License, see LICENSE.
2// Timestamps for Claude Code: https://github.com/paragpandyareal/claude-code-timestamps
3
4// Reads times back out of a session's own transcript file (JSONL), for rows
5// recorded before the mod was loaded. Pure: the caller does the one fs.read.
6
7/** True only for `<anything>/<sessionId>.jsonl`, the engine's own transcript name. */
8export function isOwnTranscript(path: string | undefined, sessionId: string | undefined): path is string {
9 if (!path || !sessionId || !/^[A-Za-z0-9-]+$/.test(sessionId)) return false
10 return path.endsWith('/' + sessionId + '.jsonl') || path.endsWith('\\' + sessionId + '.jsonl')
11}
12
13type Block = { type?: unknown; id?: unknown; tool_use_id?: unknown }
14
15type Line = {
16 type?: unknown
17 subtype?: unknown
18 uuid?: unknown
19 timestamp?: unknown
20 isMeta?: unknown
21 message?: { content?: unknown }
22}
23
24/**
25 * Row id → epoch ms for the rows the mod draws: prompts and notifications,
26 * Claude's blocks, the turn-closing lines, each tool call by its tool_use id,
27 * and each tool result as `r:<tool_use id>`. A line that does not parse is
28 * skipped.
29 */
30export function timesFromTranscript(text: string): Map<string, number> {
31 return readTranscript(text).times
32}
33
34export type TranscriptTimes = { times: Map<string, number>; lastUser?: number }
35
36/** The times, and when the person last typed. */
37export function readTranscript(text: string): TranscriptTimes {
38 const out = new Map<string, number>()
39 let lastUser: number | undefined
40 let start = 0
41 while (start < text.length) {
42 let end = text.indexOf('\n', start)
43 if (end === -1) end = text.length
44 const raw = text.slice(start, end)
45 start = end + 1
46 if (!raw.includes('"timestamp"')) continue
47 let line: Line
48 try {
49 line = JSON.parse(raw)
50 } catch {
51 continue
52 }
53 if (typeof line.uuid !== 'string' || typeof line.timestamp !== 'string') continue
54 const t = Date.parse(line.timestamp)
55 if (!Number.isFinite(t)) continue
56 const blocks = Array.isArray(line.message?.content) ? (line.message.content as Block[]) : []
57 if (line.type === 'user' && line.isMeta !== true) {
58 out.set(line.uuid, t)
59 if (!blocks.some(b => b && b.type === 'tool_result') && (lastUser === undefined || t > lastUser)) lastUser = t
60 for (const b of blocks) if (b && b.type === 'tool_result' && typeof b.tool_use_id === 'string') out.set('r:' + b.tool_use_id, t)
61 } else if (line.type === 'system' && line.subtype === 'turn_duration') out.set(line.uuid, t)
62 else if (line.type === 'assistant') {
63 out.set(line.uuid, t)
64 for (const b of blocks) if (b && b.type === 'tool_use' && typeof b.id === 'string') out.set(b.id, t)
65 }
66 }
67 return { times: out, lastUser }
68}
69