SLOPSHOPPER

prompt-clock

Shows when you sent each prompt, and when each turn started and ended, in the transcript.

newspinnerrows
v0.1.1Apache-2.0updated 2026-10-06vampik33/claude-plugins/plugins/prompt-clock
A shopper browsing a rack in a slop shop

Draws

Turn line
✻ Worked for 42s · 14:51:26 → 14:52:08
README

prompt-clock

Shows when you sent each request.

❯ 18:15:12 · run the tests and fix what fails
✻ Cogitated for 24s · 18:15:12 → 18:15:35

Only the drawing changes: the model never sees the times, so the prompt cache is untouched.

Installation

claude plugin install prompt-clock@vampik-plugins

How It Works

  • session.append stamps each typed prompt by its row id; ui.render on UserMessage puts the time in front.
  • turn.complete records each turn's end; ui.render on TurnDuration draws the line with start → end.
  • A resumed session reads earlier prompts' times from its transcript file.
  • Times from another day get the date: Oct 4 23:10:05.

Configuration

OptionDefault
secondstrue16:42:07 instead of 16:42
turnRangetruestart → end on the turn's line

Needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).

Source 3 files
hooks/register.tsx 107 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { PromptClockTimes } from "../types";
5import {
6  cap,
7  emptyTimes,
8  fmtDuration,
9  parseTranscript,
10  promptText,
11  stampText,
12  turnEnd,
13  turnRange,
14} from "./core.ts";
15
16const times = atom(
17  { plugin: "prompt-clock", key: "times" } as const,
18  emptyTimes(),
19);
20
21/** Folds the transcript file's times under what the session already stamped. */
22async function loadTranscript($: EngineInterface, path: string): Promise<void> {
23  let jsonl: string;
24  try {
25    jsonl = await $.fs.read(path);
26  } catch {
27    return; // a new session has no file yet
28  }
29  const fromFile = parseTranscript(jsonl);
30  await update($, times, (t) => ({
31    sent: cap({ ...fromFile.sent, ...t.sent }),
32    byText: cap({ ...fromFile.byText, ...t.byText }),
33    turns: cap({ ...fromFile.turns, ...t.turns }),
34  }));
35}
36
37export const register: Register = (on, options) => {
38  const seconds = options.seconds !== false;
39  const showRange = options.turnRange !== false;
40
41  // a resumed session: earlier prompts' times come from its own transcript
42  on("classic.SessionStart", async ($, e, next) => {
43    const r = await next(e);
44    if (e.source === "resume" || e.source === "startup")
45      await loadTranscript($, e.transcript_path);
46    return r;
47  });
48
49  on("session.end", async ($, e, next) => {
50    if (e.reason === "clear") await update($, times, () => emptyTimes());
51    return next(e);
52  });
53
54  // a typed prompt becomes a row: stamp it by the row's id
55  on("session.append", { door: "prompt" }, async ($, e, next) => {
56    const r = await next(e);
57    if (e.agentId || e.origin.kind !== "composer" || "deny" in r) return r;
58    const text = promptText(e.message.content);
59    if (text === undefined) return r;
60    const at = Date.now();
61    await update($, times, (t: PromptClockTimes) => ({
62      ...t,
63      sent: cap({ ...t.sent, [r.uuid]: at }),
64      byText: cap({ ...t.byText, [text]: at }),
65    }));
66    return r;
67  });
68
69  on("turn.complete", async ($, e, next) => {
70    const r = await next(e);
71    if (!e.agentId) {
72      const end = Date.now();
73      await update($, times, (t) => ({
74        ...t,
75        turns: cap({ ...t.turns, [String(e.durationMs)]: end }),
76      }));
77    }
78    return r;
79  });
80
81  on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
82    if (e.props.origin.kind !== "composer") return next(e);
83    const t = await read($, times);
84    const at =
85      (e.requestId ? t.sent[e.requestId] : undefined) ?? t.byText[e.props.text];
86    if (at === undefined) return next(e);
87    return next({
88      ...e,
89      props: {
90        ...e.props,
91        text: stampText(e.props.text, at, Date.now(), seconds),
92      },
93    });
94  });
95
96  on("ui.render", { component: "TurnDuration" }, async ($, e, next) => {
97    if (!showRange) return next(e);
98    const t = await read($, times);
99    const end = turnEnd(t.turns, e.props.durationMs);
100    if (end === undefined) return next(e);
101    // the engine's line is opaque and takes the row, so the mod draws the whole line
102    const { Text } = $.ui.resolve(e);
103    const range = turnRange(end, e.props.durationMs, Date.now(), seconds);
104    return <Text dimColor>{`✻ ${e.props.word} for ${fmtDuration(e.props.durationMs)} · ${range}`}</Text>;
105  });
106};
107
hooks/core.ts 154 lines
1/**
2 * core.ts — the pure half of prompt-clock: formatting and reading the
3 * transcript file's own timestamps. No `$`, no engine.
4 */
5
6import type { PromptClockTimes } from "../types";
7
8/** Most rows kept per map, newest win; a long session's oldest fall off. */
9export const KEEP = 2000;
10
11const MONTHS = [
12  "Jan",
13  "Feb",
14  "Mar",
15  "Apr",
16  "May",
17  "Jun",
18  "Jul",
19  "Aug",
20  "Sep",
21  "Oct",
22  "Nov",
23  "Dec",
24];
25
26const pad = (n: number) => String(n).padStart(2, "0");
27
28/** 16:42:07 (or 16:42), with "Oct 4 " in front when it is not today. */
29export function fmtTime(at: number, now: number, seconds: boolean): string {
30  const d = new Date(at);
31  const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}${seconds ? `:${pad(d.getSeconds())}` : ""}`;
32  return d.toDateString() === new Date(now).toDateString()
33    ? clock
34    : `${MONTHS[d.getMonth()]} ${d.getDate()} ${clock}`;
35}
36
37/** A turn's length as the engine's line writes it: 3s, 1m 4s, 1h 2m. */
38export function fmtDuration(ms: number): string {
39  const total = Math.round(ms / 1000);
40  const h = Math.floor(total / 3600);
41  const m = Math.floor((total % 3600) / 60);
42  const s = total % 60;
43  if (h > 0) return `${h}h ${m}m`;
44  return m > 0 ? `${m}m ${s}s` : `${s}s`;
45}
46
47/** The row's text with its send time in front. */
48export const stampText = (
49  text: string,
50  at: number,
51  now: number,
52  seconds: boolean,
53) => `${fmtTime(at, now, seconds)} · ${text}`;
54
55/** "16:42:07 → 16:45:11" for a turn that ended at `endedAt` after `durationMs`. */
56export const turnRange = (
57  endedAt: number,
58  durationMs: number,
59  now: number,
60  seconds: boolean,
61) =>
62  `${fmtTime(endedAt - durationMs, now, seconds)} → ${fmtTime(endedAt, now, seconds)}`;
63
64/** Keeps the newest `keep` entries of a map whose values are times. */
65export function cap(
66  record: Record<string, number>,
67  keep = KEEP,
68): Record<string, number> {
69  const entries = Object.entries(record);
70  if (entries.length <= keep) return record;
71  // slice(-keep) would keep everything at keep = 0
72  return Object.fromEntries(
73    entries.sort((a, b) => a[1] - b[1]).slice(entries.length - keep),
74  );
75}
76
77export const emptyTimes = (): PromptClockTimes => ({
78  sent: {},
79  byText: {},
80  turns: {},
81});
82
83/** The typed text of a user row's content, or undefined for a tool result or a meta row. */
84export function promptText(content: unknown): string | undefined {
85  if (typeof content === "string") return content;
86  if (!Array.isArray(content)) return undefined;
87  if (content.some((b) => b?.type === "tool_result")) return undefined;
88  const text = content
89    .filter((b) => b?.type === "text" && typeof b.text === "string")
90    .map((b) => b.text as string)
91    .join("\n");
92  return text || undefined;
93}
94
95/**
96 * Times from a transcript file (JSONL): each typed prompt's row by uuid and
97 * text, and each turn's end by its duration.
98 */
99export function parseTranscript(jsonl: string): PromptClockTimes {
100  const times = emptyTimes();
101  for (const line of jsonl.split("\n")) {
102    if (!line.includes('"timestamp"')) continue;
103    let row: any;
104    try {
105      row = JSON.parse(line);
106    } catch {
107      continue;
108    }
109    const at = Date.parse(row.timestamp);
110    if (!Number.isFinite(at)) continue;
111    if (
112      row.type === "system" &&
113      row.subtype === "turn_duration" &&
114      typeof row.durationMs === "number"
115    ) {
116      times.turns[String(row.durationMs)] = at;
117    } else if (
118      row.type === "user" &&
119      !row.isMeta &&
120      !row.isSidechain &&
121      typeof row.uuid === "string"
122    ) {
123      const text = promptText(row.message?.content);
124      if (text === undefined) continue;
125      times.sent[row.uuid] = at;
126      times.byText[text] = at;
127    }
128  }
129  return {
130    sent: cap(times.sent),
131    byText: cap(times.byText),
132    turns: cap(times.turns),
133  };
134}
135
136/** The end time of a turn drawn with `durationMs`: exact, else the closest within a second. */
137export function turnEnd(
138  turns: Record<string, number>,
139  durationMs: number,
140): number | undefined {
141  const exact = turns[String(durationMs)];
142  if (exact !== undefined) return exact;
143  let best: number | undefined;
144  let gap = 1000;
145  for (const [ms, at] of Object.entries(turns)) {
146    const d = Math.abs(Number(ms) - durationMs);
147    if (d <= gap) {
148      gap = d;
149      best = at;
150    }
151  }
152  return best;
153}
154
types/index.d.ts 16 lines
1/** When each typed prompt was sent, by its transcript row id; and turn ends by duration. */
2export type PromptClockTimes = {
3  /** row uuid -> ms since the epoch */
4  sent: Record<string, number>;
5  /** prompt text -> ms, for a row whose id the render does not carry */
6  byText: Record<string, number>;
7  /** a turn's durationMs -> when it ended, ms since the epoch */
8  turns: Record<string, number>;
9};
10
11declare module 'claude-code' {
12  interface PluginState {
13    'prompt-clock': { times: PromptClockTimes };
14  }
15}
16