Grok-style right-aligned clock on each sent prompt and each reply message

A dim, right-aligned clock on every prompt you send and every reply message, so you can see when each turn happened without leaving the transcript.
claude plugin marketplace add weskao/weskao-agent-plugins
claude plugin install message-clock@weskao-agent-plugins
Needs Claude Code 2.1.287 or later (mods load by default) and python3 on PATH.
Nothing to configure: once installed, the time shows beside each message, in the same gray band as the prompt.
/message-clock-update updates to the latest version (then restart Claude Code); at each session start a toast says when a newer version is published (the version list is fetched at most every 10 minutes).


/name args) with its time, and the command's output row (⎿ …) shows the same time.
Times come from the clock and the session transcript already on your disk (scripts/scan.py reads it). Nothing is sent anywhere. The only network request is the version check (at most every 10 minutes), a plain GET of the public marketplace.json on raw.githubusercontent.com.
MIT
hooks/register.tsx 127 lines1import type { EngineInterface, Register } from 'claude-code'
2import { fmt } from './shared/fmt'
3import { classify, commandEcho } from './shared/rows'
4import type { AppendedRow, Row } from './shared/rows'
5import { notifyUpdate, registerUpdate, runUpdate } from './shared/update'
6
7export { fmt }
8
9// rows drawn without a stable message id: a command's echo ('placeholder'), a queued prompt (a fresh id per redraw)
10const KEYED_BY_TEXT = new Set(['command', 'local-command', 'queued', 'mid-turn'])
11
12// a command echo stored in its collapsed form (`<command-name>` with no `<command-contents>`) is drawn by the engine
13// outside any render site (seen on 2.1.299–2.1.301), so no mod can draw on it. Stored with an empty `<command-contents>`,
14// the engine draws it through the UserMessage site instead, where ui.render below puts the typed form back and adds the time.
15// shortcut: the empty tag reaches the transcript the model reads; drop this once the engine sites that row
16const EMPTY_CONTENTS = '\n<command-contents></command-contents>'
17const withEmptyContents = <T extends { message: { content: AppendedRow['message']['content'] } }>(e: T): T | undefined => {
18 const i = e.message.content.findIndex(b => b.type === 'text' && b.text?.includes('<command-name>') && !b.text.includes('<command-contents>'))
19 if (i < 0) return undefined
20 const content = e.message.content.map((b, j) => (j === i ? { ...b, text: `${b.text}${EMPTY_CONTENTS}` } : b))
21 return { ...e, message: { ...e.message, content } }
22}
23
24// rows stored before this process (resume, reload, a --resume launch) were never appended here: their time is the
25// transcript's own; a row already stamped live keeps its stamp (prompt-toc's rescan keeps the same rule, so both agree)
26// the session whose transcript was last restored; set before the scan so a turn during it never starts another
27let restoredFor: string | undefined
28async function restore($: EngineInterface, transcript?: string) {
29 try {
30 const sid = await $.session.id()
31 restoredFor = sid
32 // shortcut: needs python3 on PATH; rows appended live are stamped without it
33 const argv = ['python3', `${$.plugin.root}/scripts/scan.py`, sid, ...(transcript ? [transcript] : [])]
34 const ran = await $.process.run(argv, { timeoutMs: 20000 })
35 if (ran.exitCode !== 0) return
36 for (const [uuid, iso, echo] of JSON.parse(ran.stdout) as [string, string, string][]) {
37 const at = Date.parse(iso)
38 if (Number.isNaN(at)) continue
39 const keys = [uuid, ...(echo ? [`text:${commandEcho(echo) ?? echo}`] : [])]
40 for (const id of keys) if ((await $.state.get({ plugin: 'message-clock', key: 'at', id })).value === undefined) await $.state.set({ plugin: 'message-clock', key: 'at', id }, at)
41 }
42 } catch {}
43}
44
45export const register: Register = on => {
46 // a launch, a reload or a --resume launch; unawaited so a big transcript never holds the first prompt
47 on('session.start', async ($, e, next) => {
48 await registerUpdate($)
49 const started = await next(e)
50 void restore($)
51 notifyUpdate($)
52 return started
53 })
54
55 on('command.run', { command: 'message-clock-update' }, async $ => ({ text: await runUpdate($) }))
56
57 // an in-process /resume raises no session.start, and classic.SessionStart never reaches a user-tier mod (the built-in
58 // cc-plugin-sec-default sends classic.* past the user tier): the first main-loop turn of a session not yet restored is
59 // the signal; unawaited as at startup
60 on('turn.start', async ($, e, next) => {
61 if (e.agentId !== undefined) return next(e)
62 const result = await next(e)
63 try {
64 if ((await $.session.id()) !== restoredFor) void restore($)
65 } catch {}
66 return result
67 })
68
69 on('session.append', async ($, e, next) => {
70 // every input and every reply text row: the time it was stored. A clock failure must never fail the append
71 let row: Row | undefined
72 let sited: typeof e | undefined
73 try {
74 row = classify(e)
75 if (row?.source === 'command' || row?.source === 'local-command') sited = withEmptyContents(e)
76 } catch {}
77 const result = await next(sited ?? e)
78
79 try {
80 if (row) {
81 const now = await $.clock.now()
82 await $.state.set({ plugin: 'message-clock', key: 'at', id: e.uuid }, now)
83 // shortcut: the same text typed twice shows the latest time on both rows, until those rows carry their message id
84 if (KEYED_BY_TEXT.has(row.source)) await $.state.set({ plugin: 'message-clock', key: 'at', id: `text:${row.text}` }, now)
85 }
86 } catch {}
87 return result
88 })
89
90 // wraps the engine's own row (composes via next) with a dim time on the right
91 on('ui.render', { component: ['UserMessage', 'AssistantMessage', 'CommandOutput'] }, async ($, e, next) => {
92 // a stored command echo (see withEmptyContents) is drawn as typed, `/name args`, not by its stored form;
93 // a shell escape (`<bash-input>`) keeps the engine's own drawing
94 const typed = e.component === 'UserMessage' && e.props.text.includes('<command-name>') ? commandEcho(e.props.text) : undefined
95 const drawn = await next(typed === undefined ? e : { ...e, props: { ...e.props, text: typed } })
96 const isOutput = e.component === 'CommandOutput'
97 // a command's output row (`⎿ Reloaded …`) also shows the command's time, found by the echo's typed form
98 let { value: at } = await $.state.get({ plugin: 'message-clock', key: 'at', id: isOutput ? `text:/${e.props.command}${e.props.args ? ` ${e.props.args}` : ''}` : e.requestId })
99 if (at === undefined && e.component === 'UserMessage') {
100 // the echo may carry the stored form (`<command-name>` tags) rather than `/name args`
101 ;({ value: at } = await $.state.get({ plugin: 'message-clock', key: 'at', id: `text:${commandEcho(e.props.text) ?? e.props.text}` }))
102 }
103 if (at === undefined) return drawn
104
105 const { Box, Text } = $.ui.resolve(e)
106 // the prompt row's gray band (theme key); padding, not margin, so the gap is gray too
107 const bg = e.component === 'UserMessage' ? 'userMessageBackground' : undefined
108 const row = (
109 <Box flexDirection="row">
110 {/* column so the engine's row stretches to full width and its gray band fills the gap */}
111 <Box flexDirection="column" flexGrow={1} flexShrink={1}>{drawn}</Box>
112 {/* a message row opens with a blank margin line; skip it so the time sits beside the text (an output row has none) */}
113 <Box flexShrink={0} marginTop={isOutput ? 0 : 1} paddingLeft={1} paddingRight={1} backgroundColor={bg}>
114 <Text dimColor backgroundColor={bg}>{fmt(at)}</Text>
115 </Box>
116 </Box>
117 )
118 if (!bg) return row
119 // one gray row above and below the prompt: the engine's opening margin line turns gray inside this band, marginTop keeps the gap above
120 return (
121 <Box flexDirection="column" marginTop={1} paddingBottom={1} backgroundColor={bg}>
122 {row}
123 </Box>
124 )
125 })
126}
127hooks/shared/fmt.ts 9 lines1// generated from mods/_shared/fmt.ts by mods/_shared/sync.py: edit the source there, then run it
2const pad = (n: number) => String(n).padStart(2, '0')
3
4// ms since epoch as local-time HH:MM:SS
5export const fmt = (ms: number) => {
6 const d = new Date(ms)
7 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
8}
9hooks/shared/rows.ts 85 lines1// generated from mods/_shared/rows.ts by mods/_shared/sync.py: edit the source there, then run it
2// Which transcript rows are the person's input or a reply, and how each reads: one rule per way a row arrives.
3// Pure (no `$`), so any mod can classify a `session.append` row and agree with the others on it.
4
5// the parts of `session.append`'s input the rules read
6export type AppendedRow = {
7 door: string
8 origin: { kind: string }
9 message: { type: string; name?: string; isMeta?: true; content: readonly { type: string; text?: string }[] }
10}
11
12// prompt: typed and sent · command: a typed slash command (`/commit`, `/compact`) · local-command: a plugin or
13// local command, echoed as a system row · mid-turn: sent while a turn ran · queued: waiting in the queue (no row
14// of the conversation) · reply: an assistant text row
15export type RowSource = 'prompt' | 'command' | 'local-command' | 'mid-turn' | 'queued' | 'reply'
16
17// text: an input as the transcript shows it (`/name args` for a command), or a reply's whole text
18export type Row = { kind: 'input' | 'reply'; source: RowSource; text: string }
19
20type Rule = { source: RowSource; read: (row: AppendedRow, text: string) => string | undefined }
21
22const textOf = (row: AppendedRow) => row.message.content.flatMap(b => (b.type === 'text' && b.text ? [b.text] : [])).join('\n')
23
24const isPersonUser = (row: AppendedRow) => row.message.type === 'user' && !row.message.isMeta
25
26// `/name args` from a slash command's stored form (`<command-name>` / `<command-args>` tags), `! cmd` from a
27// shell escape's (`<bash-input>`)
28export const commandEcho = (text: string) => {
29 const bash = /^\s*<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)?.[1]?.trim()
30 if (bash) return `! ${bash}`
31 const name = /<command-name>(.*?)<\/command-name>/.exec(text)?.[1]
32 if (!name) return undefined
33 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]?.trim()
34 return args ? `${name} ${args}` : name
35}
36
37// text the engine wrote in the person's place (`<local-command-stdout>`, `<bash-stdout>`, `<task-notification>`, ...):
38// it opens with a kebab-case tag, so one rule keeps out tags not seen yet. A command echo is typed input.
39// shortcut: a prompt that itself opens with a custom-element tag (`<my-card>`) is dropped too; check the tag name if that shows
40const ENGINE_TAG = /^\s*<[a-z]+(?:-[a-z]+)+>/
41export const isEngineText = (text: string) => ENGINE_TAG.test(text) && commandEcho(text) === undefined
42
43// shortcut: a mid-turn message is only readable from the engine's reminder wording; update if that wording changes
44const MID_TURN = /while you were working:\n([\s\S]*?)\n\nThis is how/
45
46// first match wins
47const RULES: readonly Rule[] = [
48 // a command's echo by its stored form, whatever door the engine stores it by (`/clear` and `/model` use others)
49 { source: 'command', read: (row, text) => (isPersonUser(row) && (row.door === 'command' || commandEcho(text) !== undefined) ? (commandEcho(text) ?? text) : undefined) },
50 { source: 'prompt', read: (row, text) => (isPersonUser(row) && (row.door === 'prompt' || (row.door === 'delivery' && row.origin.kind === 'composer')) ? text : undefined) },
51 { source: 'queued', read: (row, text) => (isPersonUser(row) && row.door === 'queue' ? (commandEcho(text) ?? text) : undefined) },
52 { source: 'local-command', read: (row, text) => (row.message.type === 'system' && row.message.name === 'local_command' ? commandEcho(text) : undefined) },
53 { source: 'mid-turn', read: (row, text) => (row.message.name === 'queued_command' ? MID_TURN.exec(text)?.[1] : undefined) },
54 { source: 'reply', read: (row, text) => (row.door === 'response' ? text : undefined) },
55]
56
57export const classify = (row: AppendedRow): Row | undefined => {
58 const text = textOf(row)
59 for (const rule of RULES) {
60 const read = rule.read(row, text)
61 if (read === undefined) continue
62 if (!read.trim() || read.startsWith('[Request interrupted') || (rule.source !== 'reply' && isEngineText(read))) return undefined
63 return { kind: rule.source === 'reply' ? 'reply' : 'input', source: rule.source, text: read }
64 }
65 return undefined
66}
67
68// the first line, cut to `max` characters; a pasted block's `<pasted_content id>` tags are not text
69export const oneLine = (text: string, max: number) => {
70 const line = (text.replace(/<\/?pasted_content[^>]*>/g, '').trim().split('\n')[0] ?? '').trim()
71 return line.length > max ? `${line.slice(0, max - 1)}…` : line
72}
73
74// markdown # / ## / ### lines outside ``` fences
75export const headings = (text: string, max = 72) => {
76 const found: { level: number; text: string }[] = []
77 let isFenced = false
78 for (const line of text.split('\n')) {
79 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
80 const m = !isFenced && /^(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line)
81 if (m) found.push({ level: (m[1] ?? '').length, text: oneLine(m[2] ?? '', max) })
82 }
83 return found
84}
85hooks/shared/update.ts 82 lines1// generated from mods/_shared/update.ts by mods/_shared/sync.py: edit the source there, then run it
2import type { EngineInterface } from 'claude-code'
3
4// the public marketplace the mods are published to (skills/marketplace-publish). A copy loaded from anywhere else
5// (the local dev marketplace, --plugin-dir) has no upstream to compare with: it is never checked or updated
6export const MARKETPLACE = 'weskao-agent-plugins'
7const CATALOG = `https://raw.githubusercontent.com/weskao/${MARKETPLACE}/main/.claude-plugin/marketplace.json`
8// update-kit's default (ai-accounts); a raw.githubusercontent.com GET has no API rate limit and is CDN-cached ~5 min
9const TTL = 10 * 60 * 1000
10// { at, latest }: the last fetch's time and result, reused until TTL so every session start inside it still toasts
11const CHECK = 'updateCheck'
12
13// >0 when a is newer; dotted numbers only, a pre-release suffix (`-beta`) is ignored
14export const cmpVersion = (a: string, b: string): number => {
15 const pa = a.replace(/-.*/, '').split('.').map(Number)
16 const pb = b.replace(/-.*/, '').split('.').map(Number)
17 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
18 const d = (pa[i] || 0) - (pb[i] || 0)
19 if (d) return d
20 }
21 return 0
22}
23
24// an installed plugin lives under plugins/cache/<marketplace>/<name>/<version>
25export const isPublished = (root: string) => root.includes(`/${MARKETPLACE}/`)
26
27// the newer upstream version, at most one fetch per TTL; undefined when current, unpublished, offline or unparsable
28export async function checkUpdate($: EngineInterface, now = Date.now(), root = $.plugin.root): Promise<string | undefined> {
29 if (!isPublished(root)) return
30 try {
31 const local = JSON.parse((await $.fs.read(`${root}/.claude-plugin/plugin.json`)) as string).version as string
32 const last = (await $.store.get(CHECK)) as { at?: number; latest?: string } | undefined
33 let latest = last?.latest
34 // a clock that moved backwards refetches instead of trusting the entry
35 if (typeof last?.at !== 'number' || now - last.at < 0 || now - last.at >= TTL) {
36 // stamped before the fetch: a failing network is not retried on every launch
37 await $.store.set(CHECK, { at: now })
38 const res = await $.http.fetch(CATALOG)
39 if (!res.ok) return
40 const plugins = JSON.parse(res.text).plugins as { name: string; version?: string }[]
41 latest = plugins.find(p => p.name === $.plugin.name)?.version
42 await $.store.set(CHECK, { at: now, latest })
43 }
44 return latest && local && cmpVersion(latest, local) > 0 ? latest : undefined
45 } catch {
46 return
47 }
48}
49
50// /<name>-update: refresh the marketplace, then update this plugin; the new code loads after a restart
51export async function runUpdate($: EngineInterface, root = $.plugin.root): Promise<string> {
52 const id = `${$.plugin.name}@${MARKETPLACE}`
53 if (!isPublished(root)) return `${$.plugin.name} was not installed from ${MARKETPLACE}, so it has nothing to update from. To switch: claude plugin install ${id}`
54 const out: string[] = []
55 for (const argv of [['claude', 'plugin', 'marketplace', 'update', MARKETPLACE], ['claude', 'plugin', 'update', id]]) {
56 try {
57 const r = await $.process.run(argv, { timeoutMs: 120_000 })
58 if (r.exitCode !== 0) return `\`${argv.join(' ')}\` failed:\n${(r.stderr || r.stdout).trim()}`
59 out.push(r.stdout.trim())
60 } catch (err) {
61 return `\`${argv.join(' ')}\` could not run (${String(err)}). Run it in a terminal instead.`
62 }
63 }
64 await $.store.delete(CHECK)
65 return `${out.filter(Boolean).join('\n')}\nRestart Claude Code to load the new ${$.plugin.name}.`
66}
67
68// a module may hook session.start once, so each mod calls these from its own hook and answers
69// `command.run` for `<name>-update` with runUpdate
70export async function registerUpdate($: EngineInterface) {
71 try {
72 await $.command.register({ name: `${$.plugin.name}-update`, description: `Update ${$.plugin.name} to the latest version` })
73 } catch {}
74}
75
76// call unawaited after next(e): the network never holds the first prompt
77export function notifyUpdate($: EngineInterface) {
78 void checkUpdate($)
79 .then(v => v && $.ui.toast(`${$.plugin.name} ${v} is available: run /${$.plugin.name}-update`))
80 .catch(() => {})
81}
82types/index.d.ts 10 lines1// epoch ms
2export type Stamp = number
3
4declare module 'claude-code' {
5 interface PluginState {
6 // per transcript row id: when a prompt was sent / a turn finished
7 'message-clock': { at: StateFamily<Stamp> }
8 }
9}
10