SLOPSHOPPER

turn-timer

Example: status line with the last turn duration, and a toast when a turn is slow

newcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-timer
› fix the failing auth test and add an audit log call ╭────────────────╮ │ turn-timer │ ⏺ Read(src/auth.ts) │ turn-timer off │ ⎿ 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 › /turn-timer ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ turn-timer: off
README

turn-timer

What it shows

  • A status line under the prompt with the duration of the last turn, for example last turn 12.3s.
  • A toast when a turn reaches the slow threshold, only on the transition: never on the first turn the mod sees, never twice for the same turn, and not for an interrupted turn.
  • The main loop only: an event whose agentId is set (a subagent) is ignored.
  • A switch that survives a restart.

Settings

Constants exported from hooks/logic.ts:

ConstantValueMeaning
SLOW_TURN_MS60000A turn of 60 seconds or more is slow and earns a toast.
ENABLED_KEY'turn-timer.enabled'The $.store key holding the switch.
USAGE`'usage: /turn-timer [on\off]'`The answer to an unknown argument.

Durations under a minute are floored to tenths of a second (59.95 seconds reads 59.9s), so the status never shows 60.0s while no toast fires. A new session forgets the previous turn, so its first turn never toasts.

Commands

CommandWhat it does
/turn-timerToggles the mod.
/turn-timer onTurns it on and sets the status line to on.
/turn-timer offTurns it off and sets the status line to off.
/turn-timer <anything else>Answers `usage: /turn-timer [on\off]` and changes nothing.

Each change shows a toast, turn-timer on or turn-timer off. The state is the boolean turn-timer.enabled in $.store; a missing key means on.

House conventions it follows

  • On/off switch: the command is named after the mod, toggles when bare, and keeps its state in $.store. When off, the turn hook returns next(e) untouched.
  • Pure logic in hooks/logic.ts, thin hooks/register.ts.
  • Toasts on transitions only.
  • Main loop only (the agentId check).
  • Tests that mutation-check each guard.

Tests

claude plugin test runs tests/logic.test.ts (the pure functions) and tests/register.test.ts (the hooks, with the store, the status line and the toast stubbed by the test kit). Nothing runs for real.

Unverified

  • tests/register.test.ts: the chapters do not say whether the test kit accepts an agentId field on $.turn.complete. The subagent test passes it anyway.

Remove it

Delete this folder, or take it out of CLAUDE_CODE_PLUGIN_DIRS. To forget the switch, run $.store.delete for turn-timer.enabled from a mod, or leave it: it is one boolean.

Licence

Written for this plugin; MIT, see LICENSE in this folder (a copy of the repository LICENSE). The ../LICENSE one level up covers the BeLazy167 examples, not this one.

Source 2 files
hooks/register.ts 60 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { ENABLED_KEY, USAGE, parseSwitch, shouldToast, slowToastText, statusText } from './logic.js'
4import type { Snapshot } from './logic.js'
5
6// The previous turn, to tell a new slow turn from the one already shown. Kept in memory on
7// purpose: after a restart the first turn is a fresh "first snapshot" and toasts nothing.
8let last: Snapshot | null = null
9
10// A missing key means on; only an explicit false turns the mod off.
11// $.store.get(key) resolves to the value or undefined (mods-doc-mods-interfaces chapter.md:744).
12async function isEnabled($: EngineInterface): Promise<boolean> {
13  return (await $.store.get(ENABLED_KEY)) !== false
14}
15
16export const register: Register = (on) => {
17  on('session.start', async ($, e, next) => {
18    // A new session must never compare its first turn with the last turn of the previous one.
19    last = null
20    // Register commands last: a throw here skips the rest of the hook.
21    // $.command.register and its fields name, description, argumentHint: mods-doc-mods-api chapter.md:34.
22    await $.command.register({ name: 'turn-timer', description: 'Turn the turn timer on or off', argumentHint: '[on|off]' })
23    // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; session.start row at :136).
24    return next(e)
25  })
26
27  // The command answers even when the mod is off, or it could never be turned back on.
28  // e.args is the text typed after the command name, or an empty string (mods-doc-mods-api chapter.md:40).
29  on('command.run', { command: 'turn-timer' }, async ($, e) => {
30    const choice = parseSwitch(e.args, await isEnabled($))
31    // { text } is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
32    if (choice === null) return { text: USAGE }
33    // $.store.set(key, value) takes any JSON value (mods-doc-mods-interfaces chapter.md:744).
34    await $.store.set(ENABLED_KEY, choice)
35    if (!choice) last = null
36    // The status line shows the switch until the next turn replaces it. $.ui.status(text) is one line
37    // under the prompt that stays until changed (mods-doc-mods-api chapter.md:221); $.ui.toast(text) shows a
38    // notification (mods-doc-mods-api chapter.md:222).
39    await $.ui.status(choice ? 'on' : 'off')
40    await $.ui.toast('turn-timer ' + (choice ? 'on' : 'off'))
41    // {} is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
42    return {}
43  })
44
45  on('turn.complete', async ($, e, next) => {
46    // Main loop only: a subagent's turn fires turn.complete with agentId set (mods-doc-mods-and-events
47    // chapter.md:253, mods-doc-mods-reference chapter.md:85).
48    if (e.agentId !== undefined || !(await isEnabled($))) return next(e)
49    // e.durationMs and e.isAborted: mods-doc-mods-and-events chapter.md:253; e.turnId identifies the turn
50    // (mods-doc-mods-and-events chapter.md:251; the turn.complete payload also lists turnId at mods-doc-mods-testing chapter.md:193).
51    const snap: Snapshot = { turnId: e.turnId, durationMs: e.durationMs, isAborted: e.isAborted }
52    const toast = shouldToast(last, snap)
53    last = snap
54    await $.ui.status(statusText(snap))
55    if (toast) await $.ui.toast(slowToastText(snap))
56    // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; turn.complete row at :126).
57    return next(e)
58  })
59}
60
hooks/logic.ts 52 lines
1// Pure decisions for turn-timer. No `$` here: every function takes values and returns values.
2
3/** A turn at or above this many milliseconds is slow and earns a toast. */
4export const SLOW_TURN_MS = 60_000
5
6/** The `$.store` key holding the on/off switch. A missing key means on. */
7export const ENABLED_KEY = 'turn-timer.enabled'
8
9export const USAGE = 'usage: /turn-timer [on|off]'
10
11export type Snapshot = { turnId: string; durationMs: number; isAborted: boolean }
12
13/** 800 -> "0.8s", 12345 -> "12.3s", 59950 -> "59.9s", 65000 -> "1m 05s". */
14export function formatDuration(ms: number): string {
15  // Floor to tenths, never round: 59_950 must read 59.9s, because rounding would show 60.0s
16  // while shouldToast (which compares raw milliseconds) stays silent below the threshold.
17  if (ms < 60_000) return (Math.floor(ms / 100) / 10).toFixed(1) + 's'
18  const total = Math.round(ms / 1000)
19  const seconds = String(total % 60).padStart(2, '0')
20  return Math.floor(total / 60) + 'm ' + seconds + 's'
21}
22
23export function statusText(snap: Snapshot): string {
24  const base = 'last turn ' + formatDuration(snap.durationMs)
25  return snap.isAborted ? base + ' (interrupted)' : base
26}
27
28/**
29 * Toast on the transition only: a new, slow, uninterrupted turn, with an earlier snapshot to
30 * compare against. The first snapshot (prev null) is recorded silently, and the same turnId
31 * never toasts twice.
32 */
33export function shouldToast(prev: Snapshot | null, next: Snapshot): boolean {
34  if (prev === null) return false
35  if (prev.turnId === next.turnId) return false
36  if (next.isAborted) return false
37  return next.durationMs >= SLOW_TURN_MS
38}
39
40export function slowToastText(snap: Snapshot): string {
41  return 'Slow turn: ' + formatDuration(snap.durationMs) + ' (limit ' + formatDuration(SLOW_TURN_MS) + ')'
42}
43
44/** The new switch value for `args`, or null when the argument is unknown (answer with USAGE). */
45export function parseSwitch(args: string, current: boolean): boolean | null {
46  const word = args.trim().toLowerCase()
47  if (word === '') return !current
48  if (word === 'on') return true
49  if (word === 'off') return false
50  return null
51}
52