SLOPSHOPPER

message-clock

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

rowscommandtoastprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · message-clock
› 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 › /message-clock-update ⎿ message-clock: message-clock was not installed from weskao-agent-plugins, so it has nothing to update from. To switch: claude ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

message-clock

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.

Install

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.

Use

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

The update toast: message-clock 0.2.0 is available: run /message-clock-update

message-clock demo: a resumed session keeps each row's stored time, a new slash command is stamped live

  • Prompts and replies — each is stamped with the time it was stored, including subagent replies, queued prompts and slash-command echoes.
  • Slash commands — the echo is redrawn as you typed it (/name args) with its time, and the command's output row (⎿ …) shows the same time.
  • Resumed sessions — rows stored before this process started take their time from the session transcript; a row already stamped live keeps its stamp.

A prompt, its reply, and a slash command with its output row, each with its time on the right

Privacy

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.

License

MIT

Source 5 files
hooks/register.tsx 127 lines
1import 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}
127
hooks/shared/fmt.ts 9 lines
1// 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}
9
hooks/shared/rows.ts 85 lines
1// 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}
85
hooks/shared/update.ts 82 lines
1// 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}
82
types/index.d.ts 10 lines
1// 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