SLOPSHOPPER

Timestamps

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.

newspinnerrowscommandprompttimer
v0.4.0MITupdated 2026-10-12paragpandyareal/claude-code-timestamps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · timestamps
› 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 › /timestamps ⎿ timestamps: off · Claude not told the time ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Timestamps for Claude Code

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.

MIT licence Claude Code mod Linux · macOS · Windows

A message gets a dim 14:05 under it, Claude's reply and tool calls get times too, the turn closes with "replied 14:07 · took 1m 48s", and Claude answers what time it is

How it works

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.

A real Claude Code session with the mod: a dim time under each message, tool call and reply, "replied 14:51 · took 11s" closing the turn, and Claude answering exactly what time it is and how long since the last message

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

Why I built it

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.

What it shows

WhereWhat you seeWith /timestamps mine
Your message14: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 replyThe time it was writtenNothing
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 ranNothing
Command output (/cost, /timestamps, …)The time it ranNothing
Notifications and messages from other agentsThe time they arrivedNothing
The line that closes a turn✻ replied 14:07 · took 1m 48sSame
Where nothing is drawn (claude -p, the VS Code chat panel)One line after each turn: sent 14:05 · replied 14:07 · took 1m 48sSame

A row with no recorded time shows nothing, never a guess.

Times in relative style: "just now" under a message and a reply, and "replied just now · took 2s"<br>Relative style: /timestamps relativeOnly my message has a time; the tool calls and reply look as they always do<br>Quieter: /timestamps mine
The same session in a 56-column terminal, wrapping exactly as Claude Code does<br>Narrow terminals: nothing moves

What Claude sees

[!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.
  • It says what it is. It's labelled as a timestamp from this plugin, not your words, so Claude doesn't answer it or act on it. Asked, Claude says the plugin added it.
  • It can't be misread. The date is written 2026-10-12 with the weekday, the time in 24-hour form with seconds, and the UTC offset.
  • Your time zone's name (Australia/Sydney) is sent once per session, on the first prompt, and again after the conversation is compacted.
  • The gap since your previous message is added only when it's 10 minutes or more. For shorter gaps Claude can tell from the earlier notes already in the conversation.
  • Background-task notifications get the same note.
  • Not added to slash commands or prompts other plugins send.
  • You don't see it in the transcript. It's sent as context beside your prompt, the way Claude Code adds its own reminders.
  • It goes where your prompt goes: to Anthropic, under your account, as part of the conversation. It holds the time, the UTC offset, your time zone's name (once), and sometimes the gap since your previous message. Nothing else.
  • Turn it on or off with /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.

Install

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.

Use it

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.

Options

Set them when you install, or any time with /plugin configure timestamps@claude-code-timestamps.

OptionDefaultWhat it does
Hour format (format)auto12h, 24h, or auto to follow your computer's locale
Style (style)clockclock (14:05) or relative (5m ago)
Where times appear (show)everythingeverything, or mine for your own messages only
Show turn duration (showDuration)onAdds took … to the closing line and to tool calls
Tell Claude the time (tellClaude)offAdds 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)onOn /resume and /branch, reads this session's own transcript to show times for rows from before the mod loaded

Where times are kept

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

Limits and known issues

  • More lines on screen. With everything, each reply block, tool call and command gets one more line. /timestamps mine goes back to times under your messages only.
  • Older rows are filled in on resume. Rows from before the mod loaded get their times from the session's own transcript when you /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.
  • Transcripts over 4 MB aren't read. Claude Code lets a mod read at most 4 MB from one file. For a very long session, rows from before the mod loaded show no time. Rows recorded while the mod was running always keep theirs. Nothing is shown as an error.
  • When it's turned on, Claude knows the time when you send a prompt, not as each tool finishes inside a long turn. A turn that runs for an hour still has the time it started.
  • The fallback line is labelled. Where nothing is drawn, Claude Code puts the mods' names in front of a mod's line, so it reads 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.
  • Subagents. A subagent's own prompts and replies aren't shown in the main conversation, so they get no time. Its tool calls get one.
  • Time zone. Times use the time zone of the computer Claude Code runs on. Over SSH, that's the server's (set TZ to change it, for example TZ=Australia/Sydney claude). The note Claude reads uses the same zone.
  • The Desktop app draws the times too. Its own closing line isn't Claude Code's terminal one, so there the reply time isn't shown.
  • Windows and macOS are covered by the same code and tests, and have been tried by hand only on Linux.

Troubleshooting

ProblemWhat to do
No times appearRun /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 existYour Claude Code is older than v2.1.287. Run claude --version and update.
The times are an hour or more outClaude Code is using your computer's (or server's) time zone. Set TZ, as above.
Older rows have no timeSee Older rows and Transcripts over 4 MB above. Try /resume on that session.
Claude doesn't know the timeIt's off by default. Type /timestamps claude on (about 22 tokens per prompt). The note is read with each new prompt.
Something elseOpen an issue (see Support) with your Claude Code version, operating system and terminal.

Permissions and data

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…WhenWhy
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 addedSo 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 defaultSo Claude knows the time
Saves ids and times in its own entry in Claude Code's plugin storeHalf a second after a change, and when a session endsSo times survive restarts and /resume
Reads this session's own transcript file, and only that fileOn /resume, /branch and reloads, if Fill in older messages is onTo fill in times for rows from before the mod loaded
Draws a dim line under rows and rewrites the turn's closing lineWhen Claude Code draws themTo show the times
Adds one line after each turnOnly where nothing is drawn (claude -p, the VS Code chat panel)So those users get times too
Deletes its own oldest entriesFive seconds after a session startsTo 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.

Exactly what the mod hooks, calls and reads

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.

HookWhat it does with what it sees
session.startRegisters /timestamps, reads its saved settings and this session's saved times, and (on a reload) fills in from the transcript it already knows
classic.SessionStartReads the session's id and transcript path after startup, /clear, /resume, /branch and compaction. On /resume and /branch, fills in from that transcript.
session.endSaves this session's times
session.appendNotes 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.submitAdds 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.completeFor 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, CommandOutputDraws 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 TurnDurationReplaces the text of the turn's closing line with ✻ replied … · took …
command.run for timestamps onlyAnswers its own /timestamps command. It doesn't see or change other commands.

Calls.

CallWhat for
fs.readOnly 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.deleteIts settings and one entry per session (row ids and times), in its own store
session.id, session.surfacesWhich session this is, and whether the terminal or the Desktop app is drawing
clock.after, clock.everySaving 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.invalidateAdding /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.

Notes for the directory review

Likely findingWhat 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.

Things to know

  • Times are kept on your computer, as ids and times only, never your words. Uninstalling and the step below remove them.
  • **If you turn on Tell Claude the time, Claude reads the timestamp note with every prompt (about 22 tokens each**), and it goes to Anthropic as part of the conversation, like the date Claude Code already sends. Once a session it includes your time zone's name (for example Australia/Sydney). It's off by default; /timestamps claude off turns it off again.
  • The times are when Claude Code stored each row. For your prompt, that's the moment you pressed Enter.

Privacy

Full policy: Privacy policy.

  • The plugin collects nothing and sends nothing to its author or anyone else.
  • It keeps row ids and times on your own computer, never message text.
  • Only if you turn it on (off by default), it adds one short timestamp note to each prompt for Claude (about 22 tokens: the time, the UTC offset, your time zone's name once a session, and the gap since your last message when it's long), which goes to Anthropic with your prompt.

Not in version one

  • Times on Claude's replies in the Desktop app's closing line. The Desktop app draws its own, which a mod can't reach yet.
  • The time inside a long turn. Claude is told the time with each prompt, not as each tool finishes.
  • Per-project settings. Your /timestamps choice applies everywhere.

Uninstall

  1. Run /plugin uninstall timestamps.
  2. Run /plugin marketplace remove claude-code-timestamps.
  3. Optional: delete its saved times, the file starting timestamps in ~/.claude/plugins/store/.

Learn more

Support

License

MIT © 2026 Parag Pandya.

Source 4 files
hooks/register.ts 402 lines
1// 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}
402
hooks/format.ts 170 lines
1// 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}
170
hooks/store.ts 87 lines
1// 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}
87
hooks/transcript.ts 69 lines
1// 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